openapi: 3.0.3
info:
  title: calwebapp API v2 (Beta — calServer 6.0)
  description: |
    REST-API v2 fuer das calwebapp-Kalibriermanagementsystem (calServer 6.0).

    > **Achtung:** API v2 befindet sich in aktiver Entwicklung und ist noch nicht produktionsreif.
    > Endpunkte, Antwortformate und Authentifizierung koennen sich noch aendern.

    Basiert auf Laravel 13 mit JSON:API-kompatiblem Antwortformat und Bearer-Token-Authentifizierung.

    Die produktive API v1 (Yii-basiert, Header-Auth) ist als eigenstaendige
    Spezifikation unter [openapi.yaml](openapi.yaml) verfuegbar.

    ## Uebersicht
    - **Base URL:** `https://<domain>/api/v2`
    - **Content Type (Responses):** `application/json`
    - **Content Type (Requests):** `application/json`
    - **Encoding:** UTF-8
    - **Authentifizierung:** Bearer Token (Sanctum)

    ## Antwortformat (JSON:API)
    Alle erfolgreichen Antworten folgen diesem Schema:
    ```json
    {
      "data": { ... },
      "meta": { "total": 42, "page": 1, "per_page": 25, "last_page": 2 },
      "links": { "self": "...", "next": "...", "prev": null }
    }
    ```
    Fehlerantworten:
    ```json
    {
      "error": {
        "status": 422,
        "title": "Validation Error",
        "detail": "The name field is required."
      }
    }
    ```

    ## Pagination
    - `page[size]` — Datensaetze pro Seite (Standard 25, max 100)
    - `page[number]` — Seitennummer (Standard 1)

    ## Sortierung
    - `sort` — Feldname. Praefix `-` fuer absteigende Sortierung.
    - Beispiel: `sort=-asset_number`

    ## Filterung
    - `filter[field]=value` — Exakter Filter
    - `filter[field]=like:value` — Teilstring-Suche
    - `filter[field]=notlike:value` — Negierte Teilstring-Suche (NULL-Zeilen bleiben erhalten)
    - `filter[field]=ne:value` — Ungleich (NULL-Zeilen bleiben erhalten)
    - `filter[field]=gt:value` / `lt:value` — Groesser/kleiner als
    - `filter[field]=gte:value` / `lte:value` — Groesser/kleiner oder gleich
    - `filter[field]=in:a,b,c` — Mehrfachwerte
    - `filter[field]=between:a,b` — Bereichsfilter
    - `filter[field][]=bedingung1&filter[field][]=bedingung2` — Mehrere Bedingungen pro Feld (UND-verknuepft)
    - `filter_join[field]=or` — Bedingungen eines Feldes mit ODER statt UND verknuepfen

    ## Berechtigungen
    Alle Endpunkte erfordern einen gueltigen Bearer-Token.
    Zusaetzliche Berechtigungen pro Ressource:
    - **Inventar:** `inventory_view`, `inventory_edit`, `inventory_delete`
    - **Kategorien (lesen):** Jeder authentifizierte Benutzer
    - **Kategorien (schreiben):** `inventory_edit`
    - **Felder:** Jeder authentifizierte Benutzer
    - **Additional Fields (schreiben):** `inventory_edit`

    ## Feldnamen (API v2 vs. v1)
    API v2 gibt Feldern sprechende Namen statt codierter Spaltennamen. Die
    V1-Codes (METTRACK, z.B. `I4201`) bleiben dabei **gueltige
    Feldbezeichner**: Filtern, Sortieren und Schreiben akzeptieren beide
    Schreibweisen. Ein gegen V1 geschriebenes Tool muss seine Feldnamen
    nicht anfassen. Schickt eine Anfrage beide Schreibweisen desselben
    Felds, gewinnt der lesbare Name.

    | API v2 (lesbar) | V1-Code = DB-Spalte | Beschreibung |
    |--------|-----------|--------------|
    | `asset_number` | I4201 | Inventarnummer |
    | `manufacturer` | I4202 | Hersteller |
    | `model` | I4203 | Modell |
    | `description` | I4204 | Geraetebeschreibung |
    | `serial_number` | I4206 | Seriennummer |
    | `location` | I4210 | Standort |
    | `status` | I4211 | Statuscode |
    | `calibration_interval` | I4220 | Kalibrierintervall |

    Antworten fuehren jedes Feld standardmaessig nur unter seinem API-Namen.
    Wer die V1-Codes zusaetzlich lesen will, fordert sie pro Anfrage an —
    per Query-Parameter `include_v1_names=1` oder Header
    `X-CalServer-V1-Field-Names: 1`. Die Codes kommen dann **additiv** neben
    den lesbaren Namen, nicht an deren Stelle.

    **Bestandsinstallationen:** Auf von V1 migrierten Systemen ist der
    V1-Code die Werksvorgabe des API-Namens (`api_name` = `v1_name`) — dort
    tragen schon normale Antworten die Codes, ohne jeden Schalter. Welche
    Variante eine Installation faehrt, zeigt
    `GET /api/v2/field-definitions/{table}` (Attribute `api_name` und
    `v1_name` nebeneinander).

    Die vollstaendige Feldzuordnung inkl. `v1_name` ist ueber
    `GET /api/v2/fields/{table}` abrufbar.
  version: 2.0.0
  contact:
    name: calwebapp Support
  license:
    name: Proprietary

servers:
  - url: https://{domain}
    description: Production server
    variables:
      domain:
        default: your-domain.com
        description: Your calwebapp domain
  - url: http://localhost
    description: Local development

security:
  - BearerAuth: []

tags:
  - name: auth
    description: Authentifizierung und Token-Verwaltung
  - name: health
    description: System-Health-Checks
  - name: inventory
    description: Inventar- und Geraeteverwaltung
  - name: category
    description: Kategorien fuer Inventar, Kalibrierungen, Reparaturen und Buchungen
  - name: field
    description: Feldkonfiguration und Metadaten (dynamische Feldverwaltung)
  - name: additional-field
    description: Kategorie- und statusabhaengige Feld-Overrides
  - name: calibration
    description: Kalibrierungen inkl. Messergebnisse, Kalibrierstandards und Berichte
  - name: procedure
    description: Pruefprozeduren, Prozedur-Vorlagen und Spezifikationen
  - name: test-step
    description: Pruefschritte und gefuehrte Messwertaufnahme (Procedure Capture/Run)
  - name: customer
    description: Kundenverwaltung
  - name: contact
    description: Ansprechpartner/Kontakte
  - name: location
    description: Standorte
  - name: reservation
    description: Reservierungs-/Leihkalender fuer Leihmessmittel
  - name: repair
    description: Wartung und Reparaturen
  - name: booking
    description: Auftraege (Buchungen) inkl. Artikelpositionen
  - name: price
    description: Preise, Preisgruppen und Standardartikel
  - name: document
    description: Dokumentenverwaltung (DMS)
  - name: signature
    description: Elektronische Signaturen
  - name: notepad
    description: Notizbuch-Eintraege (Termine, Erinnerungen)
  - name: ticket
    description: Support-Ticketsystem inkl. Kommentare und Anhaenge
  - name: project
    description: Support-Projekte (Ticket-Gruppierung)
  - name: device-type
    description: Geraetetypen inkl. Blueprint-Vorlagen
  - name: status
    description: Statuswerte und Status-Uebergangsregeln
  - name: test-standard
    description: Pruefnormen
  - name: audit
    description: Audit-Trail (Aenderungsverlauf, nur lesend)
  - name: utility
    description: Quick-Search, Lookup-Picker und Feldmetadaten-Hilfsendpunkte

paths:
  # ──────────────────────────────────────────────
  # AUTH
  # ──────────────────────────────────────────────
  /api/v2/auth/token:
    post:
      tags: [auth]
      summary: Bearer-Token erzeugen
      description: |
        Erzeugt einen Bearer-Token fuer die API-Authentifizierung.
        Der Token wird fuer alle weiteren Anfragen im `Authorization`-Header verwendet.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                  description: E-Mail-Adresse des Benutzers
                  example: api@example.com
                password:
                  type: string
                  format: password
                  description: Passwort des Benutzers
                device_name:
                  type: string
                  description: Geraetename fuer Token-Identifikation
                  example: api-client
            example:
              email: api@example.com
              password: "GeheimesPasswort123"
              device_name: api-client
      responses:
        '200':
          description: Token erfolgreich erzeugt
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: Bearer-Token fuer die API-Authentifizierung
                    example: "1|a1b2c3d4e5f6g7h8i9j0..."
              example:
                token: "1|a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/auth/login:
    post:
      tags: [auth]
      summary: Session-Login
      description: |
        Session-basierte Anmeldung (fuer Browser-Clients).
        Kein Token erforderlich — stattdessen wird ein Session-Cookie gesetzt.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                  example: user@example.com
                password:
                  type: string
                  format: password
      responses:
        '200':
          description: Anmeldung erfolgreich
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Authenticated"
                  user:
                    type: object
                    properties:
                      id:
                        type: string
                      email:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/auth/logout:
    post:
      tags: [auth]
      summary: Abmelden
      description: Aktuellen Token widerrufen.
      responses:
        '200':
          description: Abmeldung erfolgreich
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Logged out"

  /api/v2/auth/me:
    get:
      tags: [auth]
      summary: Aktuellen Benutzer abrufen
      description: Gibt die Daten des authentifizierten Benutzers zurueck.
      responses:
        '200':
          description: Benutzerdaten
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      email:
                        type: string
                      firstname:
                        type: string
                      lastname:
                        type: string
                      role:
                        type: string

  # ──────────────────────────────────────────────
  # HEALTH
  # ──────────────────────────────────────────────
  /api/v2/health:
    get:
      tags: [health]
      summary: System-Health-Check
      description: Gibt den Systemstatus zurueck. Keine Authentifizierung erforderlich.
      security: []
      responses:
        '200':
          description: System ist gesund
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  # ──────────────────────────────────────────────
  # INVENTORY
  # ──────────────────────────────────────────────
  /api/v2/inventories:
    get:
      tags: [inventory]
      summary: Inventarliste abrufen
      description: |
        Paginierte Liste aller Inventarobjekte inkl. zugeordneter Kategorien.
        Erfordert Permission `inventory_view`.

        **Feldnamen:** Verwendet die API-Namen der Installation (lesbar, z.B.
        `asset_number`); die V1-Codes wie `I4201` bleiben fuer Filter, Sortierung
        und Schreibzugriffe gueltig — siehe Kapitel "Feldnamen (API v2 vs. v1)".
        Die vollstaendige Zuordnung inkl. `v1_name` ist ueber
        `GET /api/v2/fields/inventory` abrufbar.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - $ref: '#/components/parameters/gridFields'
        - name: "filter[asset_number]"
          in: query
          description: "Filter nach Inventarnummer (unterstuetzt `like:` Operator)"
          schema:
            type: string
          example: "like:FLUKE"
        - name: "filter[manufacturer]"
          in: query
          description: Filter nach Hersteller
          schema:
            type: string
        - name: sort
          in: query
          description: |
            Sortierfeld. Praefix `-` fuer absteigende Sortierung.
            Beispiel: `-asset_number`
          schema:
            type: string
      responses:
        '200':
          description: Inventarliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InventoryCollection'
              example:
                data:
                  - id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    type: inventory
                    attributes:
                      asset_number: "DMM-001"
                      serial_number: "SN-1234567890"
                      description: "Digital Multimeter"
                      manufacturer: "FLUKE"
                      model: "179"
                      location: "Labor A"
                      status: 1
                      calibration_interval: 12
                      customer_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                    relationships:
                      customer:
                        data:
                          id: "45f09720-082f-5c74-9cfa-a605e2863904"
                          type: customer
                      categories:
                        - id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                          type: category
                          attributes:
                            name: "Messgeraet"
                            short_name: "MG"
                            type: inventory
                            parent_id: null
                            sort_num: 1
                            color: "#4CAF50"
                            text_color: "#FFFFFF"
                            rentable: 1
                meta:
                  total: 150
                  page: 1
                  per_page: 25
                  last_page: 6
                links:
                  self: "/api/v2/inventories?page[number]=1&page[size]=25"
                  next: "/api/v2/inventories?page[number]=2&page[size]=25"
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [inventory]
      summary: Inventar anlegen
      description: |
        Neues Inventarobjekt erstellen. Erfordert Permission `inventory_edit`.
        MTAG (UUID) wird automatisch generiert, wenn nicht angegeben.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InventoryInput'
            example:
              asset_number: "DMM-002"
              serial_number: "SN-9876543210"
              description: "Oszilloskop"
              manufacturer: "Keysight"
              model: "DSOX1204G"
              location: "Labor B"
              calibration_interval: 24
              customer_id: "45f09720-082f-5c74-9cfa-a605e2863904"
      responses:
        '201':
          description: Inventar erfolgreich erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/inventories/{mtag}:
    get:
      tags: [inventory]
      summary: Einzelnes Inventarobjekt abrufen
      description: |
        Gibt ein Inventarobjekt mit Kunden- und Kategoriezuordnung zurueck.
        Erfordert Permission `inventory_view`.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Inventarobjekt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [inventory]
      summary: Inventar aktualisieren
      description: |
        Inventarobjekt aktualisieren (Partial Update).
        Nur uebergebene Felder werden geaendert. Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InventoryInput'
            example:
              location: "Labor C"
              calibration_interval: 6
      responses:
        '200':
          description: Inventar aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [inventory]
      summary: Inventar loeschen
      description: |
        Inventarobjekt loeschen. Erfordert Permission `inventory_delete`.
        Gibt 409 Conflict zurueck, wenn zugehoerige Kalibrierungen existieren.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Konflikt — Inventar hat zugehoerige Kalibrierungen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: Conflict
                  detail: "Inventory has related calibrations and cannot be deleted."

  /api/v2/inventories/export:
    get:
      tags: [inventory]
      summary: Inventar exportieren
      description: |
        Export der Inventarliste als CSV oder JSON.
        Erfordert Permission `inventory_view`.
      parameters:
        - name: format
          in: query
          description: Exportformat
          schema:
            type: string
            enum: [csv, json]
            default: csv
        - name: columns
          in: query
          description: |
            Kommaseparierte Liste der zu exportierenden Spalten.
            Standard: asset_number, serial_number, description, manufacturer, model, location, status, calibration_interval
          schema:
            type: string
          example: "asset_number,serial_number,manufacturer,model"
      responses:
        '200':
          description: Exportdaten
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # CATEGORIES
  # ──────────────────────────────────────────────
  /api/v2/categories:
    get:
      tags: [category]
      summary: Alle Kategorien abrufen
      description: |
        Gibt alle Kategorien zurueck, sortiert nach `sort_num`.

        Kategorien koennen hierarchisch verschachtelt sein (`parent_id`).
        Jede Kategorie hat einen Typ: `inventory`, `booking`, `repair` oder `calibration`.

        **Lesen:** Jeder authentifizierte Benutzer.
      parameters:
        - name: "filter[type]"
          in: query
          description: |
            Nach Kategorietyp filtern.
            Moegliche Werte: `inventory`, `booking`, `repair`, `calibration`.
          schema:
            type: string
            enum: [inventory, booking, repair, calibration]
          example: inventory
        - name: "filter[parent_id]"
          in: query
          description: Nur Unterkategorien dieser Eltern-UUID zurueckgeben
          schema:
            type: string
            format: uuid
        - name: "filter[root_only]"
          in: query
          description: Nur Hauptkategorien (ohne Eltern) zurueckgeben
          schema:
            type: boolean
      responses:
        '200':
          description: Liste aller Kategorien
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryCollection'
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    type: category
                    attributes:
                      name: "Messgeraet"
                      short_name: "MG"
                      type: inventory
                      parent_id: null
                      sort_num: 1
                      color: "#4CAF50"
                      text_color: "#FFFFFF"
                      rentable: 1
                  - id: "7fb91c22-3a44-4e89-b0fc-8d5e6f7a8b9c"
                    type: category
                    attributes:
                      name: "Multimeter"
                      short_name: "MM"
                      type: inventory
                      parent_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                      sort_num: 1
                      color: "#2196F3"
                      text_color: "#FFFFFF"
                      rentable: 1
                  - id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                    type: category
                    attributes:
                      name: "Pruefmittel"
                      short_name: "PM"
                      type: calibration
                      parent_id: null
                      sort_num: 2
                      color: "#FF9800"
                      text_color: "#000000"
                      rentable: 0
                meta:
                  total: 3
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      tags: [category]
      summary: Kategorie anlegen
      description: |
        Neue Kategorie erstellen. Erfordert Permission `inventory_edit`.

        Unterkategorien koennen durch Angabe von `parent_id` erstellt werden.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryInput'
            examples:
              hauptkategorie:
                summary: Hauptkategorie erstellen
                value:
                  name: "Messgeraet"
                  short_name: "MG"
                  type: inventory
                  color: "#4CAF50"
                  text_color: "#FFFFFF"
                  rentable: 1
              unterkategorie:
                summary: Unterkategorie erstellen
                value:
                  name: "Multimeter"
                  short_name: "MM"
                  type: inventory
                  parent_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  sort_num: 1
                  color: "#2196F3"
      responses:
        '201':
          description: Kategorie erfolgreich erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CategoryResource'
              example:
                data:
                  id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  type: category
                  attributes:
                    name: "Messgeraet"
                    short_name: "MG"
                    type: inventory
                    parent_id: null
                    sort_num: 0
                    color: "#4CAF50"
                    text_color: "#FFFFFF"
                    rentable: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/categories/{id}:
    get:
      tags: [category]
      summary: Einzelne Kategorie abrufen
      description: Gibt eine Kategorie anhand ihrer UUID zurueck.
      parameters:
        - $ref: '#/components/parameters/categoryId'
      responses:
        '200':
          description: Kategorie
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CategoryResource'
              example:
                data:
                  id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  type: category
                  attributes:
                    name: "Messgeraet"
                    short_name: "MG"
                    type: inventory
                    parent_id: null
                    sort_num: 1
                    color: "#4CAF50"
                    text_color: "#FFFFFF"
                    rentable: 1
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [category]
      summary: Kategorie aktualisieren
      description: |
        Kategorie aktualisieren (Partial Update).
        Nur uebergebene Felder werden geaendert. `type` kann nicht geaendert werden.
        Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/categoryId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryUpdate'
            example:
              name: "Messgeraet (aktualisiert)"
              color: "#388E3C"
              sort_num: 2
      responses:
        '200':
          description: Kategorie aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CategoryResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [category]
      summary: Kategorie loeschen
      description: |
        Kategorie loeschen. Erfordert Permission `inventory_edit`.
        Gibt 409 Conflict zurueck, wenn die Kategorie Unterkategorien hat.
      parameters:
        - $ref: '#/components/parameters/categoryId'
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Konflikt — Kategorie hat Unterkategorien
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: Conflict
                  detail: "Category has child categories and cannot be deleted."

  # ──────────────────────────────────────────────
  # FIELDS (Feldkonfiguration)
  # ──────────────────────────────────────────────
  /api/v2/fields:
    get:
      tags: [field]
      summary: Verfuegbare Tabellen mit Felduebersicht abrufen
      description: |
        Gibt eine Uebersicht aller Tabellen der Feld-Registry
        (`field_definitions`) zurueck — neben den klassischen Grids auch
        `notepad`, `types`, `booking` usw. Pro Tabelle wird die Gesamtanzahl
        und die Anzahl aktiver Felder angezeigt; die Namen sind die
        kanonischen Registry-Namen (z.B. `customers`).

        Aktive Felder sind solche, bei denen mindestens eines der folgenden Flags gesetzt ist:
        `edit_mandatory`, `edit_visible` oder `view_visible`.
      responses:
        '200':
          description: Tabellenuebersicht mit Feldzaehlung
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/FieldTableSummary'
              example:
                data:
                  - table: calibration
                    field_count: 98
                    active_field_count: 45
                  - table: customers
                    field_count: 38
                    active_field_count: 20
                  - table: inventory
                    field_count: 65
                    active_field_count: 32
                  - table: location
                    field_count: 15
                    active_field_count: 8
                  - table: notepad
                    field_count: 11
                    active_field_count: 9
                  - table: repair
                    field_count: 20
                    active_field_count: 12
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/fields/{table}:
    get:
      tags: [field]
      summary: Feldkonfigurationen fuer eine Tabelle abrufen
      description: |
        Gibt die Feldkonfigurationen fuer eine bestimmte Tabelle zurueck.
        Nur Felder mit mindestens einem aktiven Flag (`edit_mandatory`, `edit_visible`
        oder `view_visible`) werden zurueckgegeben. Jedes Feld traegt neben dem
        `api_name` auch `column_name`, `storage` und `v1_name` (METTRACK-Code) —
        damit kann ein V1-Client seine Feldzuordnung vollstaendig abfragen.

        **Gueltige Tabellen:** alle Tabellen der Feld-Registry — die Liste liefert
        `GET /api/v2/fields` bzw. `GET /api/v2/field-definitions/tables`. Neben den
        kanonischen Namen werden gaengige Synonyme aufgeloest (`customer` →
        `customers`, `inventories` → `inventory`).

        **Kategorie-/Status-Overrides:** Wenn `category_uID` oder `status_uID` angegeben wird,
        werden zusaetzliche Feld-Overrides aus der `additional_field`-Tabelle eingemischt.
        Status-Overrides haben Vorrang vor Kategorie-Overrides.

        Dies ist der Nachfolger des Legacy-Endpunkts `GET /api/{resource}/rules`.
      parameters:
        - name: table
          in: path
          required: true
          description: |
            Name einer Registry-Tabelle, z.B. `inventory`, `calibration`,
            `customers`, `location`, `repair`, `notepad`, `types`, `booking`.
          schema:
            type: string
          example: inventory
        - name: category_uID
          in: query
          description: |
            UUID einer Kategorie. Wenn angegeben, werden Feld-Overrides
            fuer diese Kategorie eingemischt (z.B. zusaetzliche Pflichtfelder).
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        - name: status_uID
          in: query
          description: |
            UUID eines Status. Wenn angegeben, werden Feld-Overrides
            fuer diesen Status eingemischt. Hat Vorrang vor Kategorie-Overrides.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Feldkonfigurationen der Tabelle
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/FieldDefinitionResource'
              example:
                data:
                  - id: "asset_number:inventory"
                    type: field-definition
                    attributes:
                      api_name: asset_number
                      column_name: I4201
                      storage: column
                      v1_name: I4201
                      table_name: inventory
                      label: "Prüfmittel Nr"
                      field_type: text
                      field_type_raw: ""
                      default_value: null
                      filterable: true
                      sortable: true
                      editable: true
                      visible: true
                      grid_default: true
                      mandatory: true
                      display_order: 1
                      display_order_grid: 2
                      width: null
                      options: null
                      field_category: "Select Field Type"
                      is_user_defined: false
                      audit: true
                      allow_copy: false
                      security_message: true
                      small_view_visible: true
                      link_table_note: null
                  - id: "serial_number:inventory"
                    type: field-definition
                    attributes:
                      api_name: serial_number
                      column_name: I4206
                      storage: column
                      v1_name: I4206
                      table_name: inventory
                      label: "Seriennummer"
                      field_type: text
                      field_type_raw: ""
                      default_value: null
                      filterable: true
                      sortable: true
                      editable: true
                      visible: true
                      grid_default: true
                      mandatory: false
                      display_order: 7
                      display_order_grid: 6
                      width: null
                      options: null
                      field_category: "Select Field Type"
                      is_user_defined: false
                      audit: true
                      allow_copy: false
                      security_message: false
                      small_view_visible: true
                      link_table_note: null
                  - id: "status:inventory"
                    type: field-definition
                    attributes:
                      api_name: status
                      column_name: I4211
                      storage: column
                      v1_name: I4211
                      table_name: inventory
                      label: "Inventarstatus"
                      field_type: select
                      field_type_raw: ""
                      default_value: null
                      filterable: true
                      sortable: true
                      editable: true
                      visible: true
                      grid_default: true
                      mandatory: false
                      display_order: 8
                      display_order_grid: 9
                      width: null
                      options:
                        - value: "uuid-status-active"
                          label: "Aktiv"
                        - value: "uuid-status-inactive"
                          label: "Inaktiv"
                        - value: "uuid-status-repair"
                          label: "In Reparatur"
                      field_category: StatusDatabase
                      is_user_defined: false
                      audit: true
                      allow_copy: false
                      security_message: false
                      small_view_visible: true
                      link_table_note: "This column connects to status"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Tabelle nicht gefunden
          content:
            application/json:
              schema:
                type: object
                properties:
                  type:
                    type: string
                  title:
                    type: string
                  status:
                    type: integer
                  detail:
                    type: string
              example:
                type: "https://httpstatuses.com/404"
                title: "Not Found"
                status: 404
                detail: "No field configuration found for table 'unknown'."

  # ──────────────────────────────────────────────
  # ADDITIONAL FIELDS
  # ──────────────────────────────────────────────
  /api/v2/additional-fields:
    get:
      tags: [additional-field]
      summary: Alle Feld-Overrides abrufen
      description: |
        Gibt alle zusaetzlichen Feld-Overrides zurueck, die pro Kategorie oder Status
        definiert sind. Diese Overrides aendern die Sichtbarkeit und Pflichtangaben
        von Feldern in Abhaengigkeit der zugeordneten Kategorie oder des Status.
      parameters:
        - name: "filter[object_uID]"
          in: query
          description: Nach Kategorie- oder Status-UUID filtern
          schema:
            type: string
            format: uuid
        - name: "filter[type]"
          in: query
          description: "Nach Override-Typ filtern: `category` oder `status`"
          schema:
            type: string
            enum: [category, status]
      responses:
        '200':
          description: Liste der Feld-Overrides
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/AdditionalFieldResource'
              example:
                data:
                  - id: "af-uuid-001"
                    type: additional-field
                    attributes:
                      object_uID: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                      override_type: category
                      column_name: I4206
                      default_value: null
                      edit_visible: 1
                      edit_mandatory: 1
                      view_visible: 1
                      display_order: 5
                      empty_now: 0
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      tags: [additional-field]
      summary: Feld-Override erstellen
      description: |
        Neuen Feld-Override erstellen. Erfordert Permission `inventory_edit`.
        Ermoeglicht es, Felder fuer eine bestimmte Kategorie oder einen Status
        als sichtbar, pflichtgebunden oder mit Standardwert zu konfigurieren.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdditionalFieldInput'
            example:
              object_uID: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
              type: category
              column_name: I4206
              edit_visible: 1
              edit_mandatory: 1
              view_visible: 1
              default_value: "FLUKE"
      responses:
        '201':
          description: Feld-Override erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/AdditionalFieldResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/additional-fields/{id}:
    get:
      tags: [additional-field]
      summary: Einzelnen Feld-Override abrufen
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Feld-Overrides
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Feld-Override
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/AdditionalFieldResource'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [additional-field]
      summary: Feld-Override aktualisieren
      description: Erfordert Permission `inventory_edit`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Feld-Overrides
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdditionalFieldUpdate'
            example:
              edit_mandatory: 0
              default_value: null
      responses:
        '200':
          description: Feld-Override aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/AdditionalFieldResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [additional-field]
      summary: Feld-Override loeschen
      description: Erfordert Permission `inventory_edit`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Feld-Overrides
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # DEVICE TYPES
  # ──────────────────────────────────────────────
  /api/v2/device-types:
    get:
      tags: [device-type]
      summary: Geraetetypen auflisten
      description: Erfordert Permission `types_view`.
      parameters:
        - name: "filter[search]"
          in: query
          description: Suche in Typname, Hersteller, Modell, Typ-Code (case-insensitive)
          schema:
            type: string
          example: "Fluke"
        - name: "filter[manufacturer]"
          in: query
          schema:
            type: string
        - name: "filter[model]"
          in: query
          schema:
            type: string
        - name: sort
          in: query
          description: "`type_name` (Standard), `-manufacturer`, `model`, `type_code`"
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Geraetetypliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceTypeCollection'
              example:
                data:
                  - id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                    type: device-type
                    attributes:
                      type_name: "Fluke 179"
                      manufacturer: "Fluke"
                      model: "179"
                      type_code: "DMM-01"
                      type_version: "2.1"
                      custom_fields: {}
                      created_by_label: "Max Mustermann"
                      category_ids: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
                      category_name: "Messgeraet"
                      is_blueprint: true
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [device-type]
      summary: Geraetetyp anlegen
      description: |
        Erfordert Permission `types_edit`. `category_ids` verknuepft den Typ optional
        direkt mit Inventar-Kategorien; sind dabei Pflichtfelder der Kategorie noch
        leer, antwortet der Endpunkt mit 422 und `required_fields` statt den Typ anzulegen.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeviceTypeInput'
            example:
              type_name: "Fluke 179"
              manufacturer: "Fluke"
              model: "179"
              type_code: "DMM-01"
              category_ids: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
      responses:
        '201':
          description: Geraetetyp erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DeviceTypeResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/device-types/{tID}:
    get:
      tags: [device-type]
      summary: Einzelnen Geraetetyp abrufen
      description: Erfordert Permission `types_view`.
      parameters:
        - $ref: '#/components/parameters/tID'
      responses:
        '200':
          description: Geraetetyp
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DeviceTypeResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [device-type]
      summary: Geraetetyp aktualisieren
      description: Partial Update. Erfordert Permission `types_edit`.
      parameters:
        - $ref: '#/components/parameters/tID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeviceTypeInput'
            example:
              model: "179 ES"
      responses:
        '200':
          description: Geraetetyp aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DeviceTypeResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [device-type]
      summary: Geraetetyp loeschen
      description: Loescht auch einen eventuell vorhandenen Blueprint. Erfordert Permission `types_delete`.
      parameters:
        - $ref: '#/components/parameters/tID'
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/device-types/{tID}/duplicate:
    post:
      tags: [device-type]
      summary: Geraetetyp duplizieren
      description: |
        Kopiert Typ, Spezifikationen und Blueprint. Erfordert Permission `types_edit`.
        Kein Request-Body.
      parameters:
        - $ref: '#/components/parameters/tID'
      responses:
        '201':
          description: Kopie erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DeviceTypeResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/device-types/bulk-delete:
    post:
      tags: [device-type]
      summary: Mehrere Geraetetypen loeschen
      description: Erfordert Permission `types_delete`.
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/device-types/{tID}/blueprint:
    get:
      tags: [device-type]
      summary: Blueprint (Vorbelegung) eines Geraetetyps abrufen
      description: |
        Erfordert Permission `types_view`. Existiert noch kein Blueprint, antwortet
        der Endpunkt mit 200 und einem synthetischen Default (`enabled: false`) statt 404.
      parameters:
        - $ref: '#/components/parameters/tID'
      responses:
        '200':
          description: Blueprint
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DeviceTypeBlueprintResource'
              example:
                data:
                  id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                  type: device-type-blueprint
                  attributes:
                    type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                    enabled: true
                    copy_type_fields: true
                    copy_categories: true
                    field_defaults:
                      calibration_interval: 12
                    category_ids: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
                    created_by: null
                    created_at: "2026-01-15T10:00:00.000000Z"
                    updated_at: "2026-01-15T10:00:00.000000Z"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [device-type]
      summary: Blueprint anlegen/aktualisieren
      description: |
        Upsert — legt den Blueprint an, falls noch keiner existiert. Antwortet immer
        mit 200 (auch bei Erstanlage). Erfordert Permission `types_edit`.
      parameters:
        - $ref: '#/components/parameters/tID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeviceTypeBlueprintInput'
            example:
              enabled: true
              copy_type_fields: true
              copy_categories: true
              field_defaults:
                calibration_interval: 12
              category_ids: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
      responses:
        '200':
          description: Blueprint gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DeviceTypeBlueprintResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [device-type]
      summary: Blueprint loeschen
      description: |
        Erfordert Permission `types_edit`. Kein Fehler, wenn noch kein Blueprint existiert
        (Idempotent) — 404 nur, wenn der Geraetetyp selbst nicht existiert.
      parameters:
        - $ref: '#/components/parameters/tID'
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/device-types/{tID}/blueprint/prefill:
    get:
      tags: [device-type]
      summary: Vorbelegte Inventar-Felder aus dem Blueprint abrufen
      description: |
        Liefert die Attribut-/Zusatzfeld-Werte, mit denen ein neues Inventarobjekt
        dieses Typs vorbefuellt werden soll. Erfordert Permission `types_view`.
      parameters:
        - $ref: '#/components/parameters/tID'
      responses:
        '200':
          description: Vorbelegung
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      type:
                        type: string
                        enum: [device-type-prefill]
                      attributes:
                        type: object
                        properties:
                          type_id:
                            type: string
                            format: uuid
                          enabled:
                            type: boolean
                          attributes:
                            type: object
                            description: Vorbelegte Inventar-Attribute (gleiche api_names wie `InventoryInput`)
                            additionalProperties: true
                          custom_fields:
                            type: object
                            additionalProperties: true
                          category_ids:
                            type: array
                            items:
                              type: string
                              format: uuid
              example:
                data:
                  id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                  type: device-type-prefill
                  attributes:
                    type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                    enabled: true
                    attributes:
                      manufacturer: "Fluke"
                      model: "179"
                      calibration_interval: 12
                    custom_fields:
                      I4242: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                    category_ids: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # STATUSES
  # ──────────────────────────────────────────────
  /api/v2/statuses:
    get:
      tags: [status]
      summary: Statuswerte auflisten
      description: |
        Jeder authentifizierte Benutzer darf lesen. Nicht paginiert — liefert alle
        Treffer auf einmal.
      parameters:
        - name: "filter[type]"
          in: query
          description: "`inventory`, `calibration`, `booking`, `notepad`, `repair`, `location`, `support_tickets`"
          schema:
            type: string
          example: inventory
        - name: "filter[active]"
          in: query
          schema:
            type: integer
            enum: [0, 1]
      responses:
        '200':
          description: Statusliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/StatusResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "c6f35048-6556-5dba-602c-10eed9cb2473"
                    type: status
                    attributes:
                      title: "Aktiv"
                      short_title: "Akt."
                      description: "Geraet ist aktiv"
                      color: "#4CAF50"
                      text_color: "#FFFFFF"
                      icon: "check-circle"
                      type: inventory
                      sort_order: 1
                      active: 1
                      hide: 0
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [status]
      summary: Status anlegen
      description: Erfordert Permission `status_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StatusInput'
            example:
              title: "Aktiv"
              short_title: "Akt."
              description: "Geraet ist aktiv"
              color: "#4CAF50"
              text_color: "#FFFFFF"
              icon: "check-circle"
              type: inventory
      responses:
        '201':
          description: Status erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StatusResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/statuses/apply-config:
    get:
      tags: [status]
      summary: Statuswerte inkl. Workflow-Konfiguration abrufen
      description: |
        Wie `GET /statuses`, zusaetzlich mit Workflow-Flags (`new_order`,
        `set_new_location`, ...) und den fuer den Status konfigurierten
        `additional_fields`-Overrides. Jeder authentifizierte Benutzer darf lesen.
      parameters:
        - name: type
          in: query
          description: Statustyp (Standard `inventory`)
          schema:
            type: string
          example: inventory
      responses:
        '200':
          description: Statuswerte mit Workflow-Konfiguration
          content:
            application/json:
              schema:
                type: object
              example:
                data:
                  - id: "c6f35048-6556-5dba-602c-10eed9cb2473"
                    type: status
                    attributes:
                      title: "Aktiv"
                      short_title: "Akt."
                      description: "Geraet ist aktiv"
                      color: "#4CAF50"
                      text_color: "#FFFFFF"
                      icon: "check-circle"
                      sort_order: 1
                      counter: 0
                    flags:
                      new_order: false
                      set_remember_date: false
                      set_new_location: false
                      check_new_inventory: false
                      check_edit_inventory: false
                      new_article: false
                      new_calibration: false
                      close_metteam_calibration: false
                      mailer_report: false
                    additional_fields: []
                meta:
                  total: 1
                  type: inventory
                  additional_status_field: null
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/statuses/counts:
    get:
      tags: [status]
      summary: Datensatzzahl je Status abrufen
      description: |
        Zaehlt Datensaetze je Status fuer `inventory`, `notepad` oder `support_tickets`.
        Jeder authentifizierte Benutzer darf lesen.
      parameters:
        - name: type
          in: query
          required: true
          schema:
            type: string
            enum: [inventory, notepad, support_tickets]
          example: inventory
        - name: customer_id
          in: query
          description: Nur fuer `type=inventory`
          schema:
            type: string
            format: uuid
        - name: state
          in: query
          description: "Nur fuer `type=inventory`: `A` (aktiv), `I` (inaktiv), `D` (gelöscht), `all`"
          schema:
            type: string
            enum: [A, I, D, all]
      responses:
        '200':
          description: Statuszaehlung
          content:
            application/json:
              schema:
                type: object
              example:
                data:
                  - id: "c6f35048-6556-5dba-602c-10eed9cb2473"
                    title: "Aktiv"
                    color: "#4CAF50"
                    text_color: "#FFFFFF"
                    icon: "check-circle"
                    sort_order: 1
                    count: 42
                meta:
                  total: 150
                  type: inventory
                  additional_status_field: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: Nicht unterstuetzter Typ
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: Unsupported status type
                  detail: "Status counts are not available for type 'foo'. Supported: inventory, notepad, support_tickets."

  /api/v2/statuses/{id}:
    get:
      tags: [status]
      summary: Einzelnen Status abrufen
      parameters:
        - $ref: '#/components/parameters/statusId'
      responses:
        '200':
          description: Status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StatusResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [status]
      summary: Status aktualisieren
      description: |
        Partial Update. `type` kann nicht geaendert werden. `active`/`hide` sind nur
        beim Update editierbar. Erfordert Permission `status_edit`.
      parameters:
        - $ref: '#/components/parameters/statusId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StatusUpdate'
            example:
              color: "#388E3C"
              active: 1
      responses:
        '200':
          description: Status aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StatusResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [status]
      summary: Status loeschen
      description: |
        Loescht auch alle Status-Uebergangsregeln (`Map Status`), die diesen Status
        referenzieren. Erfordert Permission `status_delete`.
      parameters:
        - $ref: '#/components/parameters/statusId'
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # MAP STATUSES (Status-Uebergangsregeln)
  # ──────────────────────────────────────────────
  /api/v2/map-statuses:
    get:
      tags: [status]
      summary: Status-Uebergangsregeln auflisten
      description: Erfordert Permission `status_view`. Nicht paginiert.
      parameters:
        - name: "filter[status_id]"
          in: query
          schema:
            type: string
            format: uuid
        - name: "filter[target_type]"
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Liste der Uebergangsregeln
          content:
            application/json:
              schema:
                type: object
              example:
                data:
                  - id: "d4e5f6a7-b8c9-4012-3456-7890abcdef12"
                    type: map-status
                    attributes:
                      status_id: "c6f35048-6556-5dba-602c-10eed9cb2473"
                      target_type: "calibration_interval"
                      target: "12"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [status]
      summary: Status-Uebergangsregel anlegen
      description: |
        Erfordert Permission `status_edit`. Hinweis: `status_id` wird nicht gegen die
        `statuses`-Tabelle geprueft (kein FK-Constraint).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MapStatusInput'
            example:
              status_id: "c6f35048-6556-5dba-602c-10eed9cb2473"
              target_type: "calibration_interval"
              target: "12"
      responses:
        '201':
          description: Uebergangsregel erstellt
          content:
            application/json:
              schema:
                type: object
              example:
                data:
                  id: "d4e5f6a7-b8c9-4012-3456-7890abcdef12"
                  type: map-status
                  attributes:
                    status_id: "c6f35048-6556-5dba-602c-10eed9cb2473"
                    target_type: "calibration_interval"
                    target: "12"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/map-statuses/{id}:
    get:
      tags: [status]
      summary: Einzelne Status-Uebergangsregel abrufen
      description: Erfordert Permission `status_view`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Uebergangsregel
          content:
            application/json:
              schema:
                type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [status]
      summary: Status-Uebergangsregel aktualisieren
      description: Partial Update. Erfordert Permission `status_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MapStatusInput'
            example:
              target: "24"
      responses:
        '200':
          description: Uebergangsregel aktualisiert
          content:
            application/json:
              schema:
                type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [status]
      summary: Status-Uebergangsregel loeschen
      description: Erfordert Permission `status_delete`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # TEST STANDARDS (Pruefnormen)
  # ──────────────────────────────────────────────
  /api/v2/test-standards:
    get:
      tags: [test-standard]
      summary: Pruefnormen auflisten
      description: Jeder authentifizierte Benutzer darf lesen. Standard 25 / max 200 pro Seite.
      parameters:
        - name: "filter[search]"
          in: query
          description: Suche in Name, Abschnitt und Bemerkung
          schema:
            type: string
          example: "DIN EN"
        - name: sort
          in: query
          description: "`name` (Standard), `section`, `created_at`"
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Liste der Pruefnormen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TestStandardCollection'
              example:
                data:
                  - id: "b2c3d4e5-f6a7-4b89-9012-3c4d5e6f7a8b"
                    type: test-standard
                    attributes:
                      name: "DIN EN ISO 6789-1"
                      section: "6.2"
                      remark: "Drehmomentschluessel"
                      created_at: "2026-01-15T10:00:00.000000Z"
                      updated_at: "2026-01-15T10:00:00.000000Z"
                      inventories_count: 3
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [test-standard]
      summary: Pruefnorm anlegen
      description: Erfordert Permission `test_standard_edit`. `name`+`section` muessen zusammen eindeutig sein.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestStandardInput'
            example:
              name: "DIN EN ISO 6789-1"
              section: "6.2"
              remark: "Drehmomentschluessel"
      responses:
        '201':
          description: Pruefnorm erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TestStandardResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/test-standards/{id}:
    get:
      tags: [test-standard]
      summary: Einzelne Pruefnorm abrufen
      parameters:
        - $ref: '#/components/parameters/testStandardId'
      responses:
        '200':
          description: Pruefnorm
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TestStandardResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [test-standard]
      summary: Pruefnorm aktualisieren
      description: Partial Update. Erfordert Permission `test_standard_edit`.
      parameters:
        - $ref: '#/components/parameters/testStandardId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestStandardInput'
            example:
              remark: "Drehmomentschluessel, kalibriert"
      responses:
        '200':
          description: Pruefnorm aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TestStandardResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [test-standard]
      summary: Pruefnorm loeschen
      description: Loest alle Inventar-Zuordnungen. Erfordert Permission `test_standard_delete`.
      parameters:
        - $ref: '#/components/parameters/testStandardId'
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/test-standards:
    get:
      tags: [test-standard]
      summary: Pruefnormen eines Inventarobjekts auflisten
      description: Erfordert Permission `inventory_view`. Nicht paginiert.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Zugeordnete Pruefnormen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TestStandardResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [test-standard]
      summary: Pruefnorm einem Inventarobjekt zuordnen
      description: |
        Idempotent (kein Fehler bei bereits bestehender Zuordnung). Antwortet immer
        mit 201 und der Pruefnorm-Ressource. Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id:
                  type: string
                  format: uuid
                  description: UUID der Pruefnorm
            example:
              id: "b2c3d4e5-f6a7-4b89-9012-3c4d5e6f7a8b"
      responses:
        '201':
          description: Zugeordnet
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TestStandardResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/inventories/{mtag}/test-standards/{standardId}:
    delete:
      tags: [test-standard]
      summary: Pruefnorm-Zuordnung eines Inventarobjekts entfernen
      description: |
        Kein Fehler, wenn die Pruefnorm nicht zugeordnet war (204 trotzdem).
        Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: standardId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Erfolgreich entfernt
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # AUDIT TRAIL (global, nur lesend)
  # ──────────────────────────────────────────────
  /api/v2/audits:
    get:
      tags: [audit]
      summary: Aenderungsverlauf durchsuchen
      description: Erfordert Permission `user_management_view`.
      parameters:
        - name: "filter[table]"
          in: query
          description: "z.B. `inventory`, `calibration`"
          schema:
            type: string
          example: inventory
        - name: "filter[dataset_id]"
          in: query
          description: UUID des auditierten Datensatzes
          schema:
            type: string
        - name: "filter[user_id]"
          in: query
          schema:
            type: string
            format: uuid
        - name: "filter[type]"
          in: query
          description: "V1-Aktionstyp, z.B. `change_inventory_field`"
          schema:
            type: string
        - name: "filter[field_name]"
          in: query
          schema:
            type: string
        - name: sort
          in: query
          description: "`-timestamp` (Standard), `table`, `dataset_id`, `user_id`"
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Audit-Eintraege
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditCollection'
              example:
                data:
                  - id: "a1b2c3d4-5717-4562-b3fc-2c963f66a001"
                    type: audit
                    attributes:
                      timestamp: 1767434400
                      user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                      type: "change_inventory_field"
                      field_name: "I4210"
                      title: null
                      data_old: "Labor A"
                      data_new: "Labor B"
                      table: "inventory"
                      dataset_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      user_label: "Max Mustermann"
                      dataset_label: "DMM-001"
                      field_label: "Standort"
                      data_old_label: "Labor A"
                      data_new_label: "Labor B"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/audits/{id}:
    get:
      tags: [audit]
      summary: Einzelnen Audit-Eintrag abrufen
      description: Erfordert Permission `user_management_view`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Audit-Eintrag
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/AuditResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # UTILITY: LOOKUP, SEARCH, FIELD METADATA
  # ──────────────────────────────────────────────
  /api/v2/lookup/{resource}:
    get:
      tags: [utility]
      summary: Relations-Picker abfragen (Dropdown-Suche)
      description: |
        Generischer Quick-Search-Endpunkt fuer Auswahlfelder. Die pro-Ressource
        Berechtigung wird im Controller geprueft (nicht per Route-Middleware).

        **Gueltige `{resource}`-Werte:** `customers` (Permission `customers_view`),
        `inventories` (`inventory_view`), `contacts` (`customers_view`),
        `bookings` (`bookings_view`), `calibrations` (`calibration_view`),
        `repairs` (`repair_view`), `device-types` (`types_view`),
        `price-categories` (`price_category_view`), `users` (`using_API`, offen
        fuer jeden API-Nutzer). Ein unbekannter Wert liefert 404.
      parameters:
        - name: resource
          in: path
          required: true
          schema:
            type: string
            enum: [customers, inventories, contacts, bookings, calibrations, repairs, device-types, price-categories, users]
          example: inventories
        - name: q
          in: query
          description: Suchbegriff. Leer = erste Optionen (Dropdown-Vorbelegung).
          schema:
            type: string
          example: "fluke"
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
        - name: customer_id
          in: query
          description: "Eltern-Filter fuer `inventories`/`contacts`/`bookings`"
          schema:
            type: string
            format: uuid
        - name: inventory_id
          in: query
          description: "Eltern-Filter fuer `calibrations`/`repairs`"
          schema:
            type: string
            format: uuid
        - name: type
          in: query
          description: "Eltern-Filter fuer `contacts`"
          schema:
            type: string
      responses:
        '200':
          description: Treffer (kein `meta`/`links`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/LookupOptionResource'
              example:
                data:
                  - id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    type: lookup-option
                    attributes:
                      label: "DMM-001 - SN-1234567890 - FLUKE - 179"
                      columns:
                        asset_number: "DMM-001"
                        serial_number: "SN-1234567890"
                        manufacturer: "FLUKE"
                        model: "179"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/lookup/{resource}/{id}:
    get:
      tags: [utility]
      summary: Einzelne Lookup-Option abrufen
      parameters:
        - name: resource
          in: path
          required: true
          schema:
            type: string
          example: inventories
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Lookup-Option
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/LookupOptionResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/search:
    get:
      tags: [utility]
      summary: Globale Schnellsuche (Kopfzeilen-Suche/Scan-Feld)
      description: |
        Faechert einen Suchbegriff ueber `inventories`, `customers`, `bookings` und
        `calibrations` auf (in dieser Reihenfolge). Ressourcen ohne Berechtigung
        werden stillschweigend ausgelassen (kein 403). Ein leerer Suchbegriff
        liefert ein leeres Ergebnis.
      parameters:
        - name: q
          in: query
          schema:
            type: string
          example: "fluke"
        - name: limit
          in: query
          description: Max. Treffer je Ressourcengruppe
          schema:
            type: integer
            default: 5
            minimum: 1
            maximum: 20
      responses:
        '200':
          description: Treffergruppen (Gruppen ohne Treffer werden weggelassen)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        type:
                          type: string
                          enum: [search-group]
                        attributes:
                          type: object
                          properties:
                            resource:
                              type: string
                            items:
                              type: array
                              items:
                                $ref: '#/components/schemas/LookupOptionAttributes'
              example:
                data:
                  - id: inventories
                    type: search-group
                    attributes:
                      resource: inventories
                      items:
                        - label: "DMM-001 - SN-1234567890 - FLUKE - 179"
                          columns:
                            asset_number: "DMM-001"
                            manufacturer: "FLUKE"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/field-configurations:
    get:
      tags: [field]
      summary: Legacy-Feldkonfigurationen auflisten (V1-Tabellen)
      description: |
        Liest aus der geteilten V1/V2-Tabelle `field_configuration`. Fuer Tabellen,
        die bereits in die V2-native Registry migriert sind, siehe
        `/api/v2/field-definitions`. Standard 100 / max 500 pro Seite.
      parameters:
        - name: "filter[table]"
          in: query
          description: "`inventory`, `calibration`, `customer`, `location`, `repair`, `field_configuration`, `booking`, `notepad`, `device_type`, `price`, `results`, `results_test`"
          schema:
            type: string
          example: inventory
        - name: "filter[grid_only]"
          in: query
          schema:
            type: boolean
        - name: "filter[edit_visible]"
          in: query
          schema:
            type: boolean
        - name: "filter[view_visible]"
          in: query
          schema:
            type: boolean
        - name: sort
          in: query
          description: "Standard `table_name, display_order`"
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Feldkonfigurationen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/FieldConfigEntryResource'
                  meta:
                    type: object
                  links:
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/field-configurations/export:
    get:
      tags: [field]
      summary: Legacy-Feldkonfigurationen als JSON exportieren
      parameters:
        - name: "filter[table]"
          in: query
          schema:
            type: string
        - name: "selected_ids[]"
          in: query
          description: "Composite-Keys `column_name:table_name`"
          schema:
            type: array
            items:
              type: string
      responses:
        '200':
          description: JSON-Datei-Download (`field_configurations.json`)
          content:
            application/json:
              schema:
                type: object
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/field-configurations/{table}:
    get:
      tags: [field]
      summary: Feldkonfigurationen einer Tabelle abrufen
      description: |
        Hat die Tabelle bereits Eintraege in der V2-nativen Registry
        (`field_definitions`), liefert dieser Endpunkt transparent das
        `FieldDefinitionCollection`-Format statt der Legacy-Attribute.
      parameters:
        - name: table
          in: path
          required: true
          schema:
            type: string
          example: inventory
      responses:
        '200':
          description: Feldkonfigurationen der Tabelle
          content:
            application/json:
              schema:
                type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Unbekannte Tabelle
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "Unknown table 'foo'. Valid: inventory, calibration, customer, location, repair, field_configuration, booking, notepad, device_type, price, results, results_test."

  /api/v2/field-definitions:
    get:
      tags: [field]
      summary: V2-native Feld-Registry auflisten
      description: |
        Feldregistrierung fuer bereits migrierte V2-Tabellen (`field_definitions`).
        Gleiches Filter-/Sortier-Verhalten wie `/field-configurations`. Standard 100 / max 500 pro Seite.
      parameters:
        - name: "filter[table]"
          in: query
          description: "Akzeptiert Singular/Plural-Alias, z.B. `inventories`/`inventory`, `standard-articles`"
          schema:
            type: string
          example: inventory
        - name: "filter[grid_only]"
          in: query
          schema:
            type: boolean
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Feld-Registrierungen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/FieldDefinitionResource'
                  meta:
                    type: object
                  links:
                    type: object
              example:
                data:
                  - id: "serial_number:inventory"
                    type: field-definition
                    attributes:
                      api_name: serial_number
                      column_name: I4206
                      storage: column
                      v1_name: I4206
                      is_protected: true
                      deletable: false
                      active: true
                      table_name: inventory
                      label: "Seriennummer"
                      field_type: text
                      field_type_raw: "Text-40"
                      max_length: 40
                      filterable: true
                      sortable: true
                      editable: true
                      visible: true
                      grid_default: true
                      mandatory: false
                      display_order: 2
                      display_order_grid: 2
                meta:
                  total: 1
                  page: 1
                  per_page: 100
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/field-definitions/tables:
    get:
      tags: [field]
      summary: Tabellen mit V2-nativer Feld-Registrierung auflisten
      responses:
        '200':
          description: Flache Liste der Tabellennamen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: string
              example:
                data: [booking, calibration, customers, inventory, location, notepad, prices, repair, types]
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/field-definitions/templates:
    get:
      tags: [field]
      summary: Verfuegbare Feld-Vorlagen auflisten
      responses:
        '200':
          description: Vorlagen (aktuell nur `mettrack`)
          content:
            application/json:
              schema:
                type: object
              example:
                data:
                  - name: mettrack
                    label: "MetTrack (Werksvorgabe)"
                    description: "Klassischer calServer/MetTrack-Feldumfang"
                    tables: [inventory, calibration, customers, location, repair, booking, notepad, types, prices]
                    field_count: 180
                    existing_definitions: 42
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/field-definitions/meta:
    get:
      tags: [field]
      summary: Metadaten-Katalog fuer Feldtypen, Default-Variablen und Feldkategorien abrufen
      responses:
        '200':
          description: Metadaten-Katalog
          content:
            application/json:
              schema:
                type: object
              example:
                data:
                  field_types:
                    - value: "Text-40"
                      label: "Text (40 Zeichen)"
                      group: Text
                      normalized: text
                  default_value_variables:
                    - token: "[CURRENT_DATE]"
                      label: "Aktuelles Datum"
                      description: "Heutiges Datum (JJJJ-MM-TT)"
                      group: "Datum & Zeit"
                  field_categories:
                    - value: ""
                      label: "Standard (kein Auswahlfeld)"
                    - value: StatusDatabase
                      label: "Status-Datenbank"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/field-definitions/export:
    get:
      tags: [field]
      summary: V2-native Feld-Registrierungen als JSON exportieren
      parameters:
        - name: "filter[table]"
          in: query
          schema:
            type: string
      responses:
        '200':
          description: JSON-Datei-Download (`field_definitions.json`)
          content:
            application/json:
              schema:
                type: object
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/field-definitions/{table}:
    get:
      tags: [field]
      summary: Feld-Registrierungen einer Tabelle abrufen
      parameters:
        - name: table
          in: path
          required: true
          schema:
            type: string
          example: inventory
      responses:
        '200':
          description: Feld-Registrierungen der Tabelle (nicht paginiert)
          content:
            application/json:
              schema:
                type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Keine Feld-Registrierungen fuer diese Tabelle
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "No field definitions found for table 'foo'."

  # ──────────────────────────────────────────────
  # CALIBRATIONS
  # ──────────────────────────────────────────────
  /api/v2/calibrations:
    get:
      tags: [calibration]
      summary: Kalibrierungen auflisten
      description: |
        Paginierte Liste aller Kalibrierungen mit Filter/Sortierung.
        Erfordert Permission `calibration_view`. Sichtbarkeit richtet sich
        nach dem zugeordneten Inventarobjekt (`CalibrationVisibility`) —
        Kalibrierungen zu nicht sichtbaren Inventarobjekten werden ausgeblendet.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[certificate_number]"
          in: query
          description: "Filter nach Zertifikatsnummer (unterstuetzt `like:` Operator)"
          schema:
            type: string
          example: "like:CERT-48"
        - name: "filter[technician]"
          in: query
          schema:
            type: string
        - name: "filter[status]"
          in: query
          description: Filter nach Status-UUID
          schema:
            type: string
            format: uuid
        - name: sort
          in: query
          description: |
            Sortierfeld. Praefix `-` fuer absteigende Sortierung.
            Beispiel: `-next_calibration_date`
          schema:
            type: string
      responses:
        '200':
          description: Kalibrierliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CalibrationCollection'
              example:
                data:
                  - id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                    type: calibration
                    attributes:
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      customer_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                      calibration_date: "2025-06-15"
                      next_calibration_date: "2025-12-15"
                      callsheet_number: "282-123"
                      certificate_number: "CERT-48213"
                      order_number: "AUF-30271"
                      technician: "Techniker 1"
                      temperature: 22.5
                      humidity: 45.5
                      is_active: 1
                      calibration_time: "09:00:00"
                      language_code: "D"
                      data_source: "cal"
                      status: "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456"
                      cal_result: "Y"
                      external_callsheet_id: null
                      inventory_id_label: "INV-0001"
                      customer_id_label: "Test GmbH"
                      status_label: "Abgeschlossen (MET/TEAM)"
                      certificate:
                        id: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
                        name: "CERT-48213.pdf"
                        filetype: pdf
                        count: 1
                      category_ids: []
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0"
                links:
                  self: "/api/v2/calibrations?page[number]=1&page[size]=25"
                  first: "/api/v2/calibrations?page[number]=1&page[size]=25"
                  last: "/api/v2/calibrations?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [calibration]
      summary: Kalibrierung anlegen
      description: |
        Erfordert Permission `calibration_edit`. `id` (CTAG) wird automatisch
        generiert. `is_active=1` deaktiviert automatisch alle anderen
        Kalibrierungen desselben Inventarobjekts. Antwort enthaelt **kein**
        geladenes `inventory`-Relationship (`relationships.inventory` = `[]`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CalibrationInput'
            example:
              inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
              calibration_date: "2025-06-15"
              next_calibration_date: "2025-12-15"
              certificate_number: "CERT-48213"
              technician: "Techniker 1"
              is_active: 1
      responses:
        '201':
          description: Kalibrierung erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            Drei moegliche Formen: (1) doppelte `certificate_number` — Pre-Check
            vor dem Insert, verschachteltes `error`-Objekt **mit** zusaetzlichem
            `errors`-Feld (Hybrid, nicht die Standard-`ValidationError`-Antwort);
            (2) sonstige Feldvalidierung — Standard-RFC7807; (3) ueber
            `category_ids` ausgeloeste fehlende Pflichtfelder der Kategorie —
            Kurzform ganz ohne Error-Envelope (`data.required_fields_missing`).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  inventory_id:
                    - "The inventory id field is required."
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          success:
                            type: boolean
                            enum: [false]
                          required_fields_missing:
                            type: boolean
                            enum: [true]
                          required_fields:
                            type: array
                            items:
                              type: string
                      meta:
                        type: object
                        properties:
                          api_version:
                            type: string
              examples:
                doppelte_zertifikatsnummer:
                  summary: Doppelte certificate_number (Pre-Check)
                  value:
                    error:
                      status: 422
                      title: Validation Error
                      detail: "The given data was invalid."
                      errors:
                        certificate_number:
                          - "Die Zertifikatsnummer \"CERT-48213\" ist bereits vergeben."
                fehlende_pflichtfelder:
                  summary: Pflichtfelder der Kategorie fehlen
                  value:
                    data:
                      success: false
                      required_fields_missing: true
                      required_fields: ["technician"]
                    meta:
                      api_version: "2.0"

  /api/v2/calibrations/{ctag}:
    get:
      tags: [calibration]
      summary: Einzelne Kalibrierung abrufen
      description: |
        Erfordert Permission `calibration_view`. Laedt `inventory` eager —
        `relationships.inventory.data` ist hier (anders als bei den
        Schreib-Endpunkten) populated.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '200':
          description: Kalibrierung
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationResource'
              example:
                data:
                  id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                  type: calibration
                  attributes:
                    inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    customer_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                    calibration_date: "2025-06-15"
                    next_calibration_date: "2025-12-15"
                    certificate_number: "CERT-48213"
                    technician: "Techniker 1"
                    is_active: 1
                    status: "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456"
                    cal_result: "Y"
                    inventory_id_label: "INV-0001"
                    customer_id_label: "Test GmbH"
                    status_label: "Abgeschlossen (MET/TEAM)"
                    category_ids: []
                  relationships:
                    inventory:
                      data:
                        id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                        type: inventory
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [calibration]
      summary: Kalibrierung aktualisieren
      description: |
        Partial Update. Erfordert Permission `calibration_edit`. Weicht
        `status` vom aktuellen Wert ab, laeuft der Request durch die
        Status-Transition (`meta.transition`, siehe `POST .../status`);
        eine unbekannte `status`-UUID liefert dabei 422 (RFC7807). Fuer die
        doppelte-`certificate_number`- und Pflichtfeld-Faelle siehe
        `POST /calibrations`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CalibrationUpdateInput'
            example:
              certificate_number: "CERT-48213"
              technician: "Techniker 2"
      responses:
        '200':
          description: Kalibrierung aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationResource'
                  meta:
                    type: object
                    nullable: true
                    description: "`transition` nur vorhanden, wenn sich `status` geaendert hat."
                    properties:
                      transition:
                        type: object
                        properties:
                          success:
                            type: boolean
                          applied_defaults:
                            type: object
                            additionalProperties: true
                          cleared_fields:
                            type: array
                            items:
                              type: string
                          counter_updated:
                            type: string
                            nullable: true
                          reload_required:
                            type: boolean
                          follow_up_actions:
                            type: array
                            items:
                              type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Doppelte `certificate_number` (Hybrid-Form) oder fehlende
            Pflichtfelder der Kategorie (`application/json`, siehe
            `POST /calibrations`), sonstige Feldvalidierung oder unbekannte
            `status`-UUID (`application/problem+json`, RFC7807).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                validierungsfehler:
                  summary: Standard-Validierungsfehler
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      customer_id:
                        - "The customer id must be a valid UUID."
                unbekannter_status:
                  summary: Unbekannte status-UUID
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: 'Status "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456" not found for type "calibration"'
                    errors:
                      status:
                        - 'Status "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456" not found for type "calibration"'
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          success:
                            type: boolean
                            enum: [false]
                          required_fields_missing:
                            type: boolean
                            enum: [true]
                          required_fields:
                            type: array
                            items:
                              type: string
              examples:
                doppelte_zertifikatsnummer:
                  summary: Doppelte certificate_number (Pre-Check)
                  value:
                    error:
                      status: 422
                      title: Validation Error
                      detail: "The given data was invalid."
                      errors:
                        certificate_number:
                          - "Die Zertifikatsnummer \"CERT-48213\" ist bereits vergeben."
                fehlende_pflichtfelder:
                  summary: Pflichtfelder der Kategorie fehlen
                  value:
                    data:
                      success: false
                      required_fields_missing: true
                      required_fields: ["technician"]

    delete:
      tags: [calibration]
      summary: Kalibrierung loeschen
      description: |
        Erfordert Permission `calibration_delete`. Loescht zugehoerige
        `results`- und `standards`-Zeilen mit.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/bulk:
    post:
      tags: [calibration]
      summary: Mehrere Kalibrierungen in einem Request anlegen
      description: |
        Erfordert Permission `calibration_edit`. `items`: 1-100 Eintraege.
        Keine Teil-Isolation — das erste ungueltige oder Duplikat-Element
        (`certificate_number`) bricht den gesamten Batch ab (409/422).
        Antwort-Attribute sind ausschliesslich die Rohspalten, **ohne** die
        abgeleiteten `*_label`/`certificate`/`category_ids`-Begleitfelder.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [inventory_id]
                    properties:
                      inventory_id:
                        type: string
                        maxLength: 36
                      calibration_date:
                        type: string
                        maxLength: 255
                      next_calibration_date:
                        type: string
                        maxLength: 255
                      certificate_number:
                        type: string
                        maxLength: 50
                      technician:
                        type: string
                        maxLength: 36
                      is_active:
                        type: integer
                      customer_id:
                        type: string
                        maxLength: 36
            example:
              items:
                - inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                  certificate_number: "CERT-48213"
                  technician: "Techniker 1"
                  is_active: 1
                - inventory_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  technician: "Techniker 2"
                  is_active: 1
      responses:
        '201':
          description: Kalibrierungen erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CalibrationResource'
                  meta:
                    type: object
                    properties:
                      created:
                        type: integer
              example:
                data:
                  - id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                    type: calibration
                    attributes:
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      customer_id: null
                      calibration_date: null
                      next_calibration_date: null
                      certificate_number: "CERT-48213"
                      technician: "Techniker 1"
                      is_active: 1
                meta:
                  created: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/calibrations/bulk-delete:
    post:
      tags: [calibration]
      summary: Mehrere Kalibrierungen loeschen
      description: |
        Erfordert Permission `calibration_delete`. IDs ausserhalb der
        Sichtbarkeit des Nutzers werden stillschweigend uebersprungen — das
        schlaegt sich **nicht** in `skipped`/`skipped_ids` nieder (bleiben 0/[]).
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/calibrations/export:
    get:
      tags: [calibration]
      summary: Kalibrierungen exportieren
      description: |
        Erfordert Permission `calibration_view`. Unterstuetzt dieselben
        `filter[...]`/`sort`-Parameter wie `GET /calibrations`.
      parameters:
        - name: format
          in: query
          description: Exportformat
          schema:
            type: string
            enum: [csv, json, xlsx, pdf]
            default: csv
        - name: columns
          in: query
          description: |
            Kommaseparierte Spaltenliste. Standard: calibration_date,
            next_calibration_date, certificate_number, technician, is_active,
            inventory_id
          schema:
            type: string
          example: "certificate_number,technician,calibration_date"
        - name: ids
          in: query
          description: Kommaseparierte Liste von CTAGs (schraenkt den Export ein)
          schema:
            type: string
        - name: with_field_names
          in: query
          schema:
            type: boolean
      responses:
        '200':
          description: |
            Exportdatei. `format=csv|xlsx|pdf` liefert einen Binaerdownload
            (`Content-Disposition: attachment`), `format=json` eine JSON-Liste.
          content:
            text/csv:
              schema:
                type: string
                format: binary
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema:
                type: string
                format: binary
            application/pdf:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CalibrationResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/calibrations/due:
    get:
      tags: [calibration]
      summary: Faellige Kalibrierungen auflisten
      description: |
        Erfordert Permission `calibration_view`. Wie `GET /calibrations`,
        serverseitig gefiltert auf `next_calibration_date <= heute`. Laedt
        `inventory` eager — `relationships.inventory.data` ist populated.
      parameters:
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[certificate_number]"
          in: query
          schema:
            type: string
        - name: sort
          in: query
          description: "Praefix `-` fuer absteigend, Standardfeld `next_calibration_date`"
          schema:
            type: string
      responses:
        '200':
          description: Faellige Kalibrierungen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CalibrationCollection'
              example:
                data:
                  - id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                    type: calibration
                    attributes:
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      next_calibration_date: "2026-07-01"
                      certificate_number: "CERT-48213"
                      technician: "Techniker 1"
                      is_active: 1
                      inventory_id_label: "INV-0001"
                    relationships:
                      inventory:
                        data:
                          id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                          type: inventory
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0"
                links:
                  self: "/api/v2/calibrations/due?page[number]=1&page[size]=25"
                  first: "/api/v2/calibrations/due?page[number]=1&page[size]=25"
                  last: "/api/v2/calibrations/due?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/calibrations/defaults:
    get:
      tags: [calibration]
      summary: Standardwerte fuer eine neue Kalibrierung abrufen
      description: |
        Erfordert Permission `calibration_view`. Liefert Vorbelegungswerte
        (z.B. `calibration_date`) fuer ein neues Formular. Kein `id`-Feld in
        der Antwort — kein `CalibrationResource`.
      parameters:
        - name: category_id
          in: query
          description: Wird unveraendert in der Antwort gespiegelt
          schema:
            type: string
            format: uuid
        - name: status_id
          in: query
          description: Wird unveraendert in der Antwort gespiegelt
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Standardwerte
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      type:
                        type: string
                        enum: [calibration-defaults]
                      attributes:
                        type: object
                        additionalProperties: true
              example:
                data:
                  type: calibration-defaults
                  attributes:
                    calibration_date: "2026-08-03"
                    is_active: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/calibrations/{ctag}/duplicate:
    post:
      tags: [calibration]
      summary: Kalibrierung duplizieren
      description: |
        Erfordert Permission `calibration_edit`. Kein Request-Body. Kopiert
        alle Attribute inkl. `certificate_number`/`calibration_date`/
        `calibration_time` unter neuer UUID. **Kein Vorab-Check** wie bei
        Store/Update: Ist `certificate_number` gesetzt, schlaegt der
        eindeutige Index zu und liefert 409 statt 422.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der zu duplizierenden Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '201':
          description: Kopie erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            `certificate_number` der Quell-Kalibrierung ist nicht null und
            verletzt den eindeutigen Index `calibrations_certificate_number`.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/409"
                title: Conflict
                status: 409
                detail: "A record with this value already exists. Database reported: Duplicate entry 'CERT-48213' for key 'calibrations_certificate_number'."
                errors:
                  certificate_number:
                    - "This value is already in use."

  /api/v2/calibrations/{ctag}/status:
    post:
      tags: [calibration]
      summary: Status-Uebergang durchfuehren
      description: |
        Erfordert Permission `calibration_edit`. Fuehrt Pflichtfeld-Pruefung,
        Feld-Defaults und Zaehler-Updates des Zielstatus aus
        (`StatusTransitionService`). Fehlen Pflichtfelder, antwortet der
        Endpunkt mit 422 in einer nicht standardisierten Kurzform
        (`data.required_fields_missing`, **kein** `error`-Envelope). Ist die
        `status`-UUID unbekannt/inaktiv/vom falschen Typ, folgt stattdessen
        ein regulaeres RFC7807-422 mit `detail: 'Status "<uuid>" not found for
        type "calibration"'`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  maxLength: 36
                  description: Ziel-Status-UUID
            example:
              status: "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456"
      responses:
        '200':
          description: Uebergang erfolgreich
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        properties:
                          success:
                            type: boolean
                            enum: [true]
                          applied_defaults:
                            type: object
                            additionalProperties: true
                          cleared_fields:
                            type: array
                            items:
                              type: string
                          counter_updated:
                            type: string
                            nullable: true
                          reload_required:
                            type: boolean
                          follow_up_actions:
                            type: array
                            items:
                              type: string
              example:
                data:
                  id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                  type: calibration
                  attributes:
                    status: "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456"
                    status_label: "Abgeschlossen (MET/TEAM)"
                meta:
                  transition:
                    success: true
                    applied_defaults: {}
                    cleared_fields: []
                    counter_updated: null
                    reload_required: false
                    follow_up_actions: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Entweder fehlende Pflichtfelder des Zielstatus (nicht
            standardisierte Kurzform, `Content-Type: application/json`) oder
            unbekannte `status`-UUID (RFC7807, `application/problem+json`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success:
                        type: boolean
                        enum: [false]
                      required_fields_missing:
                        type: boolean
                        enum: [true]
                      required_fields:
                        type: array
                        items:
                          type: string
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  success: false
                  required_fields_missing: true
                  required_fields: ["certificate_number"]
                meta:
                  api_version: "2.0"
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: 'Status "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456" not found for type "calibration"'
                errors:
                  status:
                    - 'Status "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456" not found for type "calibration"'

  /api/v2/calibrations/{ctag}/additional-fields:
    post:
      tags: [calibration]
      summary: Zusatzfelder inkl. Status-Uebergang speichern
      description: |
        Erfordert Permission `calibration_edit`. Wie `POST .../status`,
        speichert jedoch zusaetzlich beliebige Feldwerte (`fields`) vor dem
        Uebergang. Gleiche 422-Doppelform (fehlende Pflichtfelder vs.
        unbekannter `status`).
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, fields]
              properties:
                status:
                  type: string
                  maxLength: 36
                  description: Ziel-Status-UUID
                fields:
                  type: object
                  additionalProperties: true
                  description: Freie Schluessel-Wert-Map der zu speichernden Felder
            example:
              status: "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456"
              fields:
                certificate_number: "CERT-48213"
                technician: "Techniker 1"
      responses:
        '200':
          description: Gespeichert, Uebergang erfolgreich
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        properties:
                          success:
                            type: boolean
                          applied_defaults:
                            type: object
                            additionalProperties: true
                          cleared_fields:
                            type: array
                            items:
                              type: string
                          counter_updated:
                            type: string
                            nullable: true
                          reload_required:
                            type: boolean
                          follow_up_actions:
                            type: array
                            items:
                              type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Entweder fehlende Pflichtfelder des Zielstatus (nicht
            standardisierte Kurzform, `Content-Type: application/json`) oder
            unbekannte `status`-UUID (RFC7807, `application/problem+json`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success:
                        type: boolean
                        enum: [false]
                      required_fields_missing:
                        type: boolean
                        enum: [true]
                      required_fields:
                        type: array
                        items:
                          type: string
              example:
                data:
                  success: false
                  required_fields_missing: true
                  required_fields: ["certificate_number"]
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: 'Status "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456" not found for type "calibration"'

  /api/v2/calibrations/{ctag}/sign:
    post:
      tags: [calibration]
      summary: Kalibrierung elektronisch signieren
      description: |
        Erfordert Permission `calibration_edit`. Legt eine `Signature`-Zeile
        an bzw. aktualisiert sie (Upsert je `setting_id`). `user_id` wird
        serverseitig aus dem authentifizierten Nutzer gesetzt.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature, setting_id]
              properties:
                signature:
                  type: string
                  description: Opakes Signatur-Payload (z.B. Stroke-JSON)
                setting_id:
                  type: string
                  maxLength: 36
                report_id:
                  type: string
                  maxLength: 36
                  nullable: true
            example:
              signature: '{"lines":[{"x":10,"y":20}]}'
              setting_id: "e5f6a7b8-9c0d-4e1f-b023-456789abcdef"
              report_id: "report-123"
      responses:
        '200':
          description: Signiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SignatureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
                      dms_regenerated:
                        type: boolean
              example:
                data:
                  id: "d4e5f6a7-b8c9-4012-3456-7890abcdef12"
                  type: signature
                  attributes:
                    setting_id: "e5f6a7b8-9c0d-4e1f-b023-456789abcdef"
                    user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                    value: '{"lines":[{"x":10,"y":20}]}'
                    object_id: "report-123"
                    type: null
                    link_table: calibration
                    link_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                    created_at: "2026-08-03T10:00:00.000000Z"
                    updated_at: "2026-08-03T10:00:00.000000Z"
                meta:
                  message: "Calibration signed successfully."
                  dms_regenerated: false
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/calibrations/{ctag}/dcc:
    post:
      tags: [calibration]
      summary: DCC (Digital Calibration Certificate) exportieren
      description: |
        Erfordert Permission `calibration_edit`. Exportiert ein PTB-DCC
        v3.3.0-XML. Liegt hinter einem Feature-Flag, das standardmaessig
        **deaktiviert** ist (403, solange aus).
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
        - name: include_pdf
          in: query
          description: PDF-Anhang in das DCC einbetten
          schema:
            type: boolean
            default: true
        - name: report_id
          in: query
          schema:
            type: string
      responses:
        '200':
          description: 'PTB-DCC-XML (`Content-Disposition: attachment; filename="dcc_{ctag}.xml"`)'
          content:
            application/xml:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            Entweder fehlende Permission `calibration_edit` (Standard-Envelope,
            siehe `Forbidden`) oder DCC-Feature-Flag deaktiviert (eigene,
            handgebaute Fehlermeldung — gleiche verschachtelte `error`-Form).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 403
                  title: "DCC Plugin Disabled"
                  detail: "Der DCC-Export ist nicht aktiviert. Ein Administrator kann ihn unter Feature Flags -> Report Engine aktivieren."
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Pflichtangaben fuer ein gueltiges DCC (PTB 3.3.0) fehlen, z.B. kein zugeordneter Kunde.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "DCC Export Incomplete"
                  detail: "Fuer ein gueltiges DCC (PTB 3.3.0) fehlt noch: ein zugeordneter Kunde."

  /api/v2/calibrations/{ctag}/report-dataset:
    get:
      tags: [calibration]
      summary: JasperReports-Datenquelle abrufen
      description: |
        Erfordert Permission `calibration_view`. Liefert den vollstaendigen
        JSON-Datenquellen-Vertrag fuer die Berichtserstellung — ein grosses,
        heterogenes Datenpaket, hier nur locker typisiert.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '200':
          description: Report-Datensatz
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: true
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  meta:
                    contract: calibration-certificate
                    schema_version: "1.3"
                    generated_at: "2026-08-03T10:00:00+00:00"
                    locale: de
                    certificate_number: "CERT-48213"
                  laboratory: {}
                  accreditation: {}
                  calibration:
                    id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                    calibration_date: "2025-06-15"
                    next_calibration_date: "2025-12-15"
                  device: {}
                  customer: {}
                  procedure: {}
                  environment: {}
                  report_variant: {}
                  standards: []
                  results: []
                  signatures: []
                  texts: {}
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/audits:
    get:
      tags: [calibration]
      summary: Aenderungsverlauf einer Kalibrierung abrufen
      description: |
        Erfordert Permission `calibration_view`. Paginiert; liefert
        `AuditCollection`-Eintraege mit `table=calibration`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[field_name]"
          in: query
          schema:
            type: string
            maxLength: 50
        - name: "filter[type]"
          in: query
          description: "V1-Aktionstyp, z.B. `change_calibration_field`"
          schema:
            type: string
            maxLength: 50
        - name: "filter[from]"
          in: query
          description: Unix-Zeitstempel (Bereichsanfang)
          schema:
            type: integer
        - name: "filter[to]"
          in: query
          description: Unix-Zeitstempel (Bereichsende)
          schema:
            type: integer
      responses:
        '200':
          description: Audit-Eintraege der Kalibrierung
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditCollection'
              example:
                data:
                  - id: "a1b2c3d4-5717-4562-b3fc-2c963f66a002"
                    type: audit
                    attributes:
                      timestamp: 1750000000
                      user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                      type: "change_calibration_field"
                      field_name: "certificate_number"
                      title: null
                      data_old: null
                      data_new: "CERT-48213"
                      table: "calibration"
                      dataset_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                      user_label: "Test Admin"
                      dataset_label: "CERT-48213"
                      field_label: "Zertifikatsnummer"
                      data_old_label: null
                      data_new_label: "CERT-48213"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/signatures:
    get:
      tags: [calibration]
      summary: Signaturen einer Kalibrierung auflisten
      description: |
        Erfordert Permission `calibration_view`. Nicht paginiert — alle
        Signaturen der Kalibrierung, absteigend nach `created_at`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '200':
          description: Signaturliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SignatureResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "d4e5f6a7-b8c9-4012-3456-7890abcdef12"
                    type: signature
                    attributes:
                      setting_id: "e5f6a7b8-9c0d-4e1f-b023-456789abcdef"
                      user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                      value: '{"lines":[{"x":10,"y":20}]}'
                      object_id: null
                      type: null
                      link_table: calibration
                      link_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                      created_at: "2026-08-03T10:00:00.000000Z"
                      updated_at: "2026-08-03T10:00:00.000000Z"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/measurements:
    get:
      tags: [calibration]
      summary: Prozedur-Messwerte einer Kalibrierung auflisten
      description: |
        Erfordert Permission `calibration_view`. Nicht paginiert, sortiert
        nach `row_num`. Nur lesend — es existieren keine Schreib-Endpunkte
        fuer diese Ressource.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '200':
          description: Messwertliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProcedureMeasurementResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "b7c8d9e0-1234-4a5b-8c6d-9e0f1a2b3c4d"
                    type: procedure-measurement
                    attributes:
                      calibration_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                      test_step_id: "7c1a2b3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
                      procedure_run_id: null
                      row_num: 1
                      test_step_no: "1.001"
                      kind: numeric
                      source: procedure
                      tol_ref: "Nominal"
                      test_status: "Pass"
                      rating: null
                      measured_value: "10.0"
                      measured_prefix: null
                      measured_unit: "V"
                      measured_base: "10.0"
                      nominal_base: "10.0"
                      lower_limit_base: "9.9"
                      upper_limit_base: "10.1"
                      error_base: "0.0"
                      tol_err: "0.0"
                      uncertainty: "0.05"
                      engine_version: "v1-legacy"
                      captured_by: null
                      captured_at: "2026-08-03T10:00:00.000000Z"
                      created_at: "2026-08-03T10:00:00.000000Z"
                      updated_at: "2026-08-03T10:00:00.000000Z"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # CALIBRATION RESULTS (Messergebnisse)
  # ──────────────────────────────────────────────
  /api/v2/calibrations/{ctag}/results:
    get:
      tags: [calibration]
      summary: Messergebnisse einer Kalibrierung auflisten
      description: |
        Erfordert Permission `calibration_view`. Nicht paginiert, sortiert
        nach `row_num`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '200':
          description: Ergebnisliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ResultResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "1:9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                    type: result
                    attributes:
                      row_number: 1
                      calibration_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      test_description: "Test Point 1"
                      fixed_quantity: "10"
                      variable_quantity: "10.1"
                      lower_limit: "9.9"
                      upper_limit: "10.1"
                      pass_fail: "Pass"
                      expanded_uncertainty: "0.5"
                      standard_uncertainty: "0.1"
                      coverage_factor: "2"
                      tolerance_reference: "Nominal"
                      tolerance_positive: "0.2"
                      tolerance_negative: "-0.2"
                      standard_deviation: "0.1"
                      comment: null
                      images: null
                      created_at: "2026-08-03T10:00:00.000000Z"
                      updated_at: "2026-08-03T10:00:00.000000Z"
                meta:
                  total: 3
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [calibration]
      summary: Einzelnes Messergebnis anlegen
      description: |
        Erfordert Permission `calibration_edit`. Ist `row_number` nicht
        angegeben, wird automatisch `max(row_number)+1` vergeben.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                row_number:
                  type: integer
                  nullable: true
                  minimum: 1
                  description: Auto-vergeben (max+1), wenn nicht angegeben
              additionalProperties: true
            example:
              row_number: 4
              test_description: "New Point"
              fixed_quantity: "42"
              pass_fail: "Fail"
      responses:
        '201':
          description: Messergebnis erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ResultResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    put:
      tags: [calibration]
      summary: Messergebnisse in Bulk aktualisieren/anlegen
      description: |
        Erfordert Permission `calibration_edit`. Upsert je `row_number`.
        Antwort ist die komplette, neu geladene Ergebnisliste — nicht nur
        die geaenderten Zeilen (gleiche Form wie `GET .../results`).
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [results]
              properties:
                results:
                  type: array
                  items:
                    type: object
                    required: [row_number]
                    properties:
                      row_number:
                        type: integer
                        minimum: 1
                    additionalProperties: true
            example:
              results:
                - row_number: 1
                  pass_fail: "Fail"
                - row_number: 7
                  test_description: "Added"
                  pass_fail: "Pass"
      responses:
        '200':
          description: Aktualisierte Ergebnisliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ResultResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/calibrations/{ctag}/results/import:
    post:
      tags: [calibration]
      summary: Messergebnisse importieren
      description: |
        Erfordert Permission `calibration_edit`. 1-5000 Eintraege. Fehlt
        `row_number` bei einem Eintrag, wird er in Eingabereihenfolge
        angehaengt.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [results]
              properties:
                results:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: object
                    properties:
                      row_number:
                        type: integer
                        nullable: true
                        minimum: 1
                    additionalProperties: true
            example:
              results:
                - test_description: "Imported Point 1"
                  pass_fail: "Pass"
                - test_description: "Imported Point 2"
                  pass_fail: "Pass"
      responses:
        '200':
          description: Import abgeschlossen — komplette Ergebnisliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ResultResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      imported:
                        type: integer
              example:
                data: []
                meta:
                  total: 5
                  imported: 2
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/calibrations/{ctag}/results/{rowNum}:
    delete:
      tags: [calibration]
      summary: Einzelnes Messergebnis loeschen
      description: Erfordert Permission `calibration_edit`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
        - name: rowNum
          in: path
          required: true
          description: "`row_number` des Messergebnisses"
          schema:
            type: integer
          example: 1
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Handgebaute 404 (nicht ueber `findOrFail`) — verschachteltes
            `error`-Objekt statt RFC7807.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 404
                  title: Not Found
                  detail: "Result not found."

  # ──────────────────────────────────────────────
  # CALIBRATION STANDARDS (Referenznormale)
  # ──────────────────────────────────────────────
  /api/v2/calibrations/{ctag}/standards:
    get:
      tags: [calibration]
      summary: Referenznormale einer Kalibrierung auflisten
      description: |
        Erfordert Permission `calibration_view`. Nicht paginiert. Laedt
        `standard_equipment` (inkl. dessen aktiver Kalibrierung) eager.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '200':
          description: Liste der Referenznormale
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/StandardResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "7c1a2b3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
                    type: standard
                    attributes:
                      standard_id: "7c1a2b3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
                      calibration_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      standard_equipment:
                        id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                        asset_number: "REF-001"
                        serial_number: "SN-REF-001"
                        manufacturer: "Fluke"
                        model: "5522A"
                        description: null
                        last_calibration:
                          id: "d1e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f"
                          calibration_date: "2025-06-01"
                          next_calibration_date: "2026-06-01"
                          certificate_number: "REF-CERT-42"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [calibration]
      summary: Referenznormal zuordnen
      description: |
        Erfordert Permission `calibration_edit`. `inventory_id` ist das
        kalibrierte Objekt (nicht das Referenznormal). `standard_equipment`
        ist in der Antwort `null` (nur bei `GET` eager-geladen).
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [standard_id, inventory_id]
              properties:
                standard_id:
                  type: string
                  maxLength: 80
                inventory_id:
                  type: string
                  maxLength: 80
                  description: MTAG des kalibrierten Inventarobjekts
            example:
              standard_id: "7c1a2b3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
              inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
      responses:
        '201':
          description: Zugeordnet
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    put:
      tags: [calibration]
      summary: Referenznormal-Zuordnung austauschen
      description: |
        Erfordert Permission `calibration_edit`. `standard_id`/`inventory_id`
        identifizieren die bestehende Zeile, `new_standard_id` ist der Ersatz.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [standard_id, inventory_id, new_standard_id]
              properties:
                standard_id:
                  type: string
                  maxLength: 80
                  description: Aktueller Wert (zur Identifikation der Zeile)
                inventory_id:
                  type: string
                  maxLength: 80
                new_standard_id:
                  type: string
                  maxLength: 80
                  description: Ersatz-Referenznormal
            example:
              standard_id: "7c1a2b3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
              inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
              new_standard_id: "8d2b3c4e-5f6a-4b7c-9d8e-0f1a2b3c4d5e"
      responses:
        '200':
          description: Zuordnung aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Handgebaute 404 (nicht ueber `findOrFail`) — verschachteltes
            `error`-Objekt statt RFC7807.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 404
                  title: Not Found
                  detail: "Standard not found for this calibration."
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [calibration]
      summary: Referenznormal-Zuordnung entfernen
      description: |
        Erfordert Permission `calibration_edit`. DELETE mit Body.
        Antwortet immer mit 204, auch wenn keine Zeile passte.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [standard_id, inventory_id]
              properties:
                standard_id:
                  type: string
                  maxLength: 80
                inventory_id:
                  type: string
                  maxLength: 80
            example:
              standard_id: "7c1a2b3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
              inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
      responses:
        '204':
          description: Entfernt (oder war nie zugeordnet — kein Fehler)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # CALIBRATION REPORTS
  # ──────────────────────────────────────────────
  /api/v2/calibrations/{ctag}/reports:
    get:
      tags: [calibration]
      summary: Verfuegbare Berichte einer Kalibrierung auflisten
      description: |
        Erfordert Permission `calibration_view`. Listet sichtbare
        `ReportSetting`-Zeilen (`grid_name=calibration`, `active=1`),
        gefiltert nach Rolle/Gruppe des Nutzers. Nicht paginiert.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '200':
          description: Berichtsliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportSettingResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
                    type: report-setting
                    attributes:
                      button_name: "Kalibrierbericht"
                      menu: null
                      format: pdf
                      folder_name: calibration
                      report_name: "calibration_report.jrxml"
                      is_label: false
                      is_print: false
                      email_attachment: true
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/reports/{reportId}/parameters:
    get:
      tags: [calibration]
      summary: Berichtsparameter abrufen
      description: |
        Erfordert Permission `calibration_view`. 404 auch dann, wenn der
        Bericht existiert, aber Rolle/Gruppe des Nutzers ihn nicht sehen
        darf (bewusst 404 statt 403).
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
        - name: reportId
          in: path
          required: true
          description: UUID des Berichts (`ReportSetting.uID`)
          schema:
            type: string
            format: uuid
          example: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
      responses:
        '200':
          description: Berichtsparameter (oft leer)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportParameterResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: Start_Date
                    type: report-parameter
                    attributes:
                      name: Start_Date
                      type: Date
                      default: null
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/reports/{reportId}/preview:
    get:
      tags: [calibration]
      summary: Bericht als PDF-Vorschau rendern
      description: |
        Erfordert Permission `calibration_view`. Rendert immer PDF,
        unabhaengig vom `format` der `ReportSetting`. Gleiche 404/502-Faelle
        wie `POST .../reports/generate`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
        - name: reportId
          in: path
          required: true
          description: UUID des Berichts
          schema:
            type: string
            format: uuid
          example: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
        - name: "parameters[Name]"
          in: query
          description: "Report-Parameter als Query-String, z.B. `parameters[Start_Date]=2026-01-01`"
          schema:
            type: string
      responses:
        '200':
          description: 'PDF-Vorschau (`Content-Disposition: inline`)'
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          description: Report-Runner konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  /api/v2/calibrations/{ctag}/report:
    post:
      tags: [calibration]
      summary: Bericht generieren (Kurzform-Pfad)
      description: |
        Erfordert Permission `calibration_view` (**nicht** `calibration_edit`).
        Alias fuer `POST /calibrations/{ctag}/reports/generate` — identischer
        Handler, identische Request-/Response-Form.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  description: UUID des Berichts (`ReportSetting.uID`)
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
              format: pdf
      responses:
        '200':
          description: 'Berichtsdatei (`Content-Disposition: attachment`, `Content-Type` je nach `format`)'
          content:
            application/pdf:
              schema:
                type: string
                format: binary
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema:
                type: string
                format: binary
            application/vnd.openxmlformats-officedocument.wordprocessingml.document:
              schema:
                type: string
                format: binary
            text/csv:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: Report-Runner konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  /api/v2/calibrations/{ctag}/reports/generate:
    post:
      tags: [calibration]
      summary: Bericht generieren
      description: |
        Erfordert Permission `calibration_view` (**nicht** `calibration_edit`).
        Identischer Handler wie `POST /calibrations/{ctag}/report` — beide
        Pfade sind registriert und gleichwertig.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  description: UUID des Berichts (`ReportSetting.uID`)
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
              format: pdf
      responses:
        '200':
          description: 'Berichtsdatei (`Content-Disposition: attachment`, `Content-Type` je nach `format`)'
          content:
            application/pdf:
              schema:
                type: string
                format: binary
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema:
                type: string
                format: binary
            application/vnd.openxmlformats-officedocument.wordprocessingml.document:
              schema:
                type: string
                format: binary
            text/csv:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: Report-Runner konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  /api/v2/calibrations/{ctag}/reports/send:
    post:
      tags: [calibration]
      summary: Bericht per E-Mail versenden
      description: |
        Erfordert Permission `calibration_view` (**nicht** `calibration_edit`).
        Generiert den Bericht und uebergibt ihn dem Mailer. **Achtung:**
        Meldet der Mailer einen Fehlversand, antwortet der Endpunkt mit 502
        und derselben `MailerMessageResource`-Form (`status: "failed"`,
        `error_message` gefuellt) statt eines generischen Fehlers.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id, to_address, subject, body]
              properties:
                report_id:
                  type: string
                to_address:
                  type: string
                  format: email
                  maxLength: 255
                subject:
                  type: string
                  maxLength: 255
                body:
                  type: string
                cc_address:
                  type: string
                  maxLength: 255
                  nullable: true
                bcc_address:
                  type: string
                  maxLength: 255
                  nullable: true
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
              to_address: "customer@example.com"
              subject: "Calibration Report"
              body: "Bitte finden Sie das Kalibrierzertifikat im Anhang."
      responses:
        '201':
          description: Mailer hat den Versand angenommen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/MailerMessageResource'
              example:
                data:
                  id: "c1d2e3f4-5678-4a9b-8c0d-1e2f3a4b5c6d"
                  type: mailer-message
                  attributes:
                    status: sent
                    error_message: null
                    to_address: "customer@example.com"
                    subject: "Calibration Report"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: |
            Entweder Report-Runner-Fehler (`ErrorResponse`, `title: "Report
            Generation Failed"`) oder Mailer meldet Fehlversand (dieselbe
            `MailerMessageResource`-Form wie bei 201, aber `status: "failed"`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/MailerMessageResource'
              examples:
                report_runner_fehler:
                  summary: Report-Runner-Fehler
                  value:
                    error:
                      status: 502
                      title: Report Generation Failed
                      detail: "Report runner returned a non-success status."
                mailer_fehlversand:
                  summary: Mailer meldet Fehlversand
                  value:
                    data:
                      id: "c1d2e3f4-5678-4a9b-8c0d-1e2f3a4b5c6d"
                      type: mailer-message
                      attributes:
                        status: failed
                        error_message: "SMTP connect() failed."
                        to_address: "customer@example.com"
                        subject: "Calibration Report"
        '503':
          description: Mailer-Konfiguration fehlt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 503
                  title: Mailer Configuration Missing
                  detail: "No mailer is configured for this installation."

  /api/v2/calibrations/{ctag}/reports/save-to-dms:
    post:
      tags: [calibration]
      summary: Bericht generieren und im DMS ablegen
      description: Erfordert Permission `calibration_view` (**nicht** `calibration_edit`).
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                file_setting_id:
                  type: string
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
              format: pdf
      responses:
        '201':
          description: Im DMS abgelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
              example:
                data:
                  id: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
                  type: document
                  attributes:
                    name: "Kalibrierschein_DMM-001"
                    hash: "a1b2c3d4e5f6"
                    major_version: 1
                    minor_version: 0
                    version: "1.0"
                    is_latest: true
                    is_existed: true
                    filename: "CERT-48213.pdf"
                    filetype: pdf
                    type: certificate
                    created: 1767434400
                    created_by: "45f09720-082f-5c74-9cfa-a605e2863904"
                    link_table: calibration
                    link_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                    report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
                    status: 0
                    action_title: null
                    released_first: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Entweder Validierungsfehler (RFC7807) oder DMS-Ablage
            fehlgeschlagen (verschachteltes `error`-Objekt mit zusaetzlichem
            `candidates`-Feld).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  report_id:
                    - "The report id field is required."
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                      title:
                        type: string
                      detail:
                        type: string
                      candidates:
                        type: array
                        nullable: true
                        items:
                          type: string
              example:
                error:
                  status: 422
                  title: "DMS-Ablage fehlgeschlagen"
                  detail: "Kein passendes Dokumenten-Setting fuer diesen Bericht gefunden."
                  candidates: null
        '502':
          description: Report-Runner konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  /api/v2/calibrations/{ctag}/reports/release:
    post:
      tags: [calibration]
      summary: Bericht generieren, ablegen und freigeben
      description: |
        Erfordert Permission `calibration_view` (**nicht** `calibration_edit`).
        Erzeugt und archiviert **ein** Dokument je passender
        `document_release`-Regel (`approve: true`) — bei mehreren passenden
        Regeln entsprechend mehrere Dokumente.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG (UUID) der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                file_setting_id:
                  type: string
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
      responses:
        '201':
          description: Freigegeben und im DMS abgelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocumentResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
                    type: document
                    attributes:
                      name: "Kalibrierschein_DMM-001"
                      major_version: 1
                      minor_version: 0
                      version: "1.0"
                      is_latest: true
                      is_existed: true
                      filename: "CERT-48213.pdf"
                      filetype: pdf
                      type: certificate
                      created: 1767434400
                      link_table: calibration
                      link_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                      report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
                      status: 1
                      action_title: "Freigegeben"
                      released_first: 1767434400
                meta:
                  total: 2
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Keine Freigabe-Regel fuer Rolle/Gruppe des Nutzers konfiguriert
            (verschachteltes `error`-Objekt).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Keine Freigabe konfiguriert"
                  detail: "Fuer diesen Bericht ist keine Freigabe-Regel fuer Ihre Rolle/Gruppe konfiguriert."
        '502':
          description: Report-Runner konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  /api/v2/reports/health:
    get:
      tags: [calibration]
      summary: Erreichbarkeit des Report-Runners pruefen
      description: |
        Nur Basis-Authentifizierung (`using_API`) — **keine** zusaetzliche
        Permission (insbesondere nicht `calibration_view`). Un-envelopetes
        Antwortformat, kein `data`/`error`-Wrapper.
      responses:
        '200':
          description: Report-Runner erreichbar
          content:
            application/json:
              schema:
                type: object
                properties:
                  report_runner:
                    type: string
                    enum: [available]
              example:
                report_runner: available
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          description: Report-Runner nicht erreichbar
          content:
            application/json:
              schema:
                type: object
                properties:
                  report_runner:
                    type: string
                    enum: [unavailable]
              example:
                report_runner: unavailable

  # ──────────────────────────────────────────────
  # SIGNATURES (generisch, entitaetsuebergreifend)
  # ──────────────────────────────────────────────
  /api/v2/signatures:
    get:
      tags: [calibration]
      summary: Signaturen (generisch, entitaetsuebergreifend) auflisten
      description: |
        Erfordert Permission `calibration_view`. Paginiert, aber mit
        handgebautem `meta` (`total`,`page`,`per_page`,`last_page`) **ohne**
        `links`-Schluessel (Unterschied zu `CalibrationCollection`). Die
        `filter.*`-Parameter sind reine Gleichheitsfilter — nicht die
        `like:`/`gt:`/`in:`-Mini-Sprache der Standard-Listenendpunkte.
      parameters:
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter.link_table"
          in: query
          description: "z.B. `calibration`, `repair`"
          schema:
            type: string
          example: calibration
        - name: "filter.link_id"
          in: query
          schema:
            type: string
        - name: "filter.user_id"
          in: query
          schema:
            type: string
            format: uuid
        - name: "filter.setting_id"
          in: query
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Signaturliste (absteigend nach `created_at`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SignatureResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
              example:
                data:
                  - id: "d4e5f6a7-b8c9-4012-3456-7890abcdef12"
                    type: signature
                    attributes:
                      setting_id: "e5f6a7b8-9c0d-4e1f-b023-456789abcdef"
                      user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                      value: '{"lines":[{"x":10,"y":20}]}'
                      object_id: null
                      type: null
                      link_table: calibration
                      link_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
                      created_at: "2026-08-03T10:00:00.000000Z"
                      updated_at: "2026-08-03T10:00:00.000000Z"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [calibration]
      summary: Signatur anlegen (generisch)
      description: |
        Erfordert Permission `calibration_edit`. `user_id` wird serverseitig
        aus dem authentifizierten Nutzer gesetzt (kein Input). Antwort
        enthaelt **kein** `meta` (Unterschied zu `POST /calibrations/{ctag}/sign`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [setting_id, value, link_table]
              properties:
                setting_id:
                  type: string
                  maxLength: 36
                value:
                  type: string
                  description: Freies Signatur-Payload
                object_id:
                  type: string
                  maxLength: 36
                  nullable: true
                type:
                  type: string
                  maxLength: 50
                  nullable: true
                link_table:
                  type: string
                  maxLength: 50
                  description: "Entitaetstabelle, z.B. `calibration`, `repair`"
                link_id:
                  type: string
                  maxLength: 36
                  nullable: true
            example:
              setting_id: "e5f6a7b8-9c0d-4e1f-b023-456789abcdef"
              value: '{"lines":[]}'
              link_table: calibration
              link_id: "9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
      responses:
        '201':
          description: Signatur erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SignatureResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/signatures/{id}:
    get:
      tags: [calibration]
      summary: Einzelne Signatur abrufen
      description: Erfordert Permission `calibration_view`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID der Signatur
          schema:
            type: string
            format: uuid
          example: "d4e5f6a7-b8c9-4012-3456-7890abcdef12"
      responses:
        '200':
          description: Signatur
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SignatureResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [calibration]
      summary: Signatur loeschen
      description: Erfordert Permission `calibration_edit`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID der Signatur
          schema:
            type: string
            format: uuid
          example: "d4e5f6a7-b8c9-4012-3456-7890abcdef12"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # PROCEDURES
  # ──────────────────────────────────────────────
  /api/v2/procedures:
    get:
      tags: [procedure]
      summary: Pruefprozeduren auflisten
      description: |
        Erfordert Permission `procedure_view`. `filter[search]` durchsucht
        `procedure_name`/`calibration_item`/`calibration_method` (LIKE);
        `filter[type_id]` filtert auf Prozeduren, die diesem Geraetetyp als
        Standard zugeordnet sind. Weitere `filter[<spalte>]` unterstuetzen die
        generischen Operatoren (`like:`/`ne:`/`gt:`/`in:`/`between:`). Standard-
        Sortierung `procedure_name` aufsteigend.
      parameters:
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[search]"
          in: query
          schema:
            type: string
          example: "Drehmoment"
        - name: "filter[type_id]"
          in: query
          description: Geraetetyp-UUID (tID)
          schema:
            type: string
            format: uuid
        - name: sort
          in: query
          description: "Standard `procedure_name`. Praefix `-` fuer absteigend."
          schema:
            type: string
      responses:
        '200':
          description: Prozedurliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProcedureCollection'
              example:
                data:
                  - id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                    type: procedure
                    attributes:
                      procedure_name: "Drehmomentschluessel — Vergleichskalibrierung"
                      calibration_item: "Digital Multimeter"
                      calibration_method: "Vergleich"
                      procedure_description: "Pruefung nach Werksstandard."
                      measurement_conditions: "23 °C, 50 % r.F."
                      standards: "DIN EN ISO 6789"
                      scope: "Werkslabor"
                      created_at: "2026-07-01T10:00:00.000000Z"
                      updated_at: "2026-07-01T10:00:00.000000Z"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                links:
                  self: "/api/v2/procedures?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [procedure]
      summary: Pruefprozedur anlegen
      description: Erfordert Permission `procedure_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProcedureInput'
            example:
              procedure_name: "Drehmomentschluessel — Vergleich"
              calibration_item: "Drehmomentschluessel"
              calibration_method: "Vergleich"
              measurement_conditions: "23 °C, 50 % r.F."
              standards: "DIN EN ISO 6789"
              scope: "Werkslabor"
      responses:
        '201':
          description: Prozedur erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedures/{id}:
    get:
      tags: [procedure]
      summary: Einzelne Pruefprozedur abrufen
      description: Erfordert Permission `procedure_view`.
      parameters:
        - name: id
          in: path
          required: true
          description: uID der Prozedur
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '200':
          description: Prozedur
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [procedure]
      summary: Pruefprozedur aktualisieren
      description: |
        Partial Update — nur uebergebene Felder werden geaendert.
        Erfordert Permission `procedure_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProcedureUpdateInput'
            example:
              scope: "Kalibrierlabor Nord"
      responses:
        '200':
          description: Prozedur aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [procedure]
      summary: Pruefprozedur loeschen
      description: |
        Erfordert Permission `procedure_delete`. Entfernt zusaetzlich alle
        `procedure_types`-Verknuepfungen (siehe `GET .../types`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedures/bulk-delete:
    post:
      tags: [procedure]
      summary: Mehrere Pruefprozeduren loeschen
      description: |
        Erfordert Permission `procedure_delete`. Ungeprueftes Loeschen —
        `skipped` ist bei dieser Ressource immer `0`.
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/procedures/{id}/duplicate:
    post:
      tags: [procedure]
      summary: Pruefprozedur duplizieren
      description: |
        Erfordert Permission `procedure_edit`. Kein Request-Body. Kopiert Name
        (Suffix " (Kopie)") und `procedure_types`-Verknuepfungen — NICHT die
        Pruefschritte oder Vorlagen der Prozedur.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '201':
          description: Kopie erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedures/{id}/types:
    get:
      tags: [procedure]
      summary: Zugeordnete Geraetetypen einer Prozedur auflisten
      description: Erfordert Permission `procedure_view`. Nicht paginiert, kein `meta.total`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '200':
          description: Zugeordnete Geraetetypen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DeviceTypeResource'
              example:
                data:
                  - id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                    type: device-type
                    attributes:
                      type_name: "Fluke 179"
                      manufacturer: "Fluke"
                      model: "179"
                      type_code: "DMM-01"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [procedure]
      summary: Prozedur einem Geraetetyp zuordnen
      description: |
        Erfordert Permission `procedure_edit`. `type_id` wird NICHT gegen die
        `types`-Tabelle geprueft (kein `exists`). Antwortet mit einem schlanken
        `{"status":"assigned"}` OHNE `data`-Umschlag. Idempotent bei erneuter
        Zuordnung.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type_id]
              properties:
                type_id:
                  type: string
                  maxLength: 80
            example:
              type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
      responses:
        '200':
          description: Zuordnung angelegt (oder bereits vorhanden)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [assigned]
              example:
                status: assigned
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedures/{id}/types/{typeId}:
    delete:
      tags: [procedure]
      summary: Geraetetyp-Zuordnung einer Prozedur entfernen
      description: |
        Erfordert Permission `procedure_edit`. Antwortet immer mit 204 — auch
        wenn keine solche Zuordnung existierte (stiller No-Op, NIE 404).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
        - name: typeId
          in: path
          required: true
          schema:
            type: string
          example: "b3e1c2d3-4567-8901-abcd-ef1234567890"
      responses:
        '204':
          description: Entfernt (oder war nicht vorhanden)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # PROCEDURE TEMPLATES
  # ──────────────────────────────────────────────
  /api/v2/procedure-templates:
    get:
      tags: [procedure]
      summary: Prozedur-Vorlagen auflisten
      description: |
        Erfordert Permission `procedure_template_view`. **Abweichendes
        Antwortformat** (von Hand gebaut statt Standard-Paginator): `meta` hat
        kein `api_version`, es gibt KEIN `links`-Objekt. Kein `sort`-Parameter
        (immer `created_at` absteigend). `latest_only` wird auf PRAESENZ statt
        Wahrheitswert geprueft — `?latest_only=0` filtert also trotzdem.
      parameters:
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[procedure_id]"
          in: query
          schema:
            type: string
            format: uuid
        - name: "filter[link_table]"
          in: query
          schema:
            type: string
          example: procedures
        - name: "filter[link_id]"
          in: query
          schema:
            type: string
        - name: "filter[search]"
          in: query
          description: Suche im Vorlagennamen (LIKE)
          schema:
            type: string
        - name: latest_only
          in: query
          description: Praesenz-Flag — jeder mitgesendete Wert (auch `0`) aktiviert den Filter.
          schema:
            type: string
      responses:
        '200':
          description: Vorlagenliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
              example:
                data:
                  - id: "d4a1b2c3-5e6f-4789-9abc-def012345678"
                    type: procedure-template
                    attributes:
                      procedure_id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                      name: "DMM Kalibrierverfahren"
                      file_name: "a1b2c3d4e5f6g7h8i9j0.json"
                      file_type: json
                      kind: internal
                      kind_label: "Eigene Messwertvorlage"
                      hash: "a1b2c3d4e5f6g7h8i9j0"
                      major_version: 1
                      minor_version: 0
                      is_latest: true
                      is_existed: true
                      approved_by: null
                      file_size: 245678
                      released: release
                      link_table: procedures
                      link_id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                      created_at: "2026-07-01T10:00:00.000000Z"
                      updated_at: "2026-07-02T09:30:00.000000Z"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [procedure]
      summary: Prozedur-Vorlage hochladen
      description: |
        Erfordert Permission `procedure_template_edit`. Multipart-Upload
        (max. 50 MB); startet immer bei Version `0.1`. `name` faellt ohne Angabe
        auf den Dateinamen-Stamm zurueck.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                  maxLength: 255
                procedure_id:
                  type: string
                  maxLength: 36
            example:
              name: "DMM Kalibrierverfahren"
              procedure_id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '201':
          description: Vorlage erstellt (Version 0.1)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedure-templates/{id}:
    get:
      tags: [procedure]
      summary: Einzelne Prozedur-Vorlage abrufen
      description: Erfordert Permission `procedure_template_view`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '200':
          description: Vorlage
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [procedure]
      summary: Prozedur-Vorlage aktualisieren (Metadaten)
      description: |
        Partial Update — nur Metadaten, keine neue Datei/Version (dafuer siehe
        `/new-version`). `procedure_id` hat keine `exists`-Pruefung und
        aktualisiert NICHT `link_table`/`link_id`. Erfordert Permission
        `procedure_template_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 255
                procedure_id:
                  type: string
                  maxLength: 36
                  nullable: true
            example:
              name: "DMM Kalibrierverfahren (2026)"
      responses:
        '200':
          description: Vorlage aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [procedure]
      summary: Prozedur-Vorlage loeschen
      description: |
        Erfordert Permission `procedure_template_delete`. War die geloeschte
        Version `is_latest`, wird automatisch die naechsthoehere Version zur
        aktuellen befoerdert.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedure-templates/bulk-delete:
    post:
      tags: [procedure]
      summary: Mehrere Prozedur-Vorlagen loeschen
      description: |
        Erfordert Permission `procedure_template_delete`. **Antwort enthaelt hier
        nur `{"deleted": N}`** — kein `skipped`/`skipped_ids`-Schluessel (gegen
        das gemeinsame Schema kompatibel, da beide Felder dort nullable sind).
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/procedure-templates/{id}/download:
    get:
      tags: [procedure]
      summary: Vorlagendatei herunterladen
      description: |
        Erfordert Permission `procedure_template_view`. `Content-Type` entspricht
        dem MIME-Typ der Datei, `Content-Disposition: attachment; filename={file_name}`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '200':
          description: Dateiinhalt
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Zwei moegliche Ursachen mit unterschiedlicher Form: Standard-404
            (Vorlage selbst nicht gefunden) oder die physische Datei fehlt auf
            dem Storage (verschachtelte, handgebaute Form).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "ProcedureTemplate not found."
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 404
                  title: Not Found
                  detail: "The template file could not be found on disk."

  /api/v2/procedure-templates/{id}/versions:
    get:
      tags: [procedure]
      summary: Alle Versionen einer Vorlage abrufen
      description: |
        Erfordert Permission `procedure_template_view`. Alle Zeilen mit
        gleichem `hash`, absteigend nach `major_version`/`minor_version`.
        Nicht paginiert.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '200':
          description: Versionsliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedure-templates/{id}/new-version:
    post:
      tags: [procedure]
      summary: Neue Version einer Vorlage hochladen
      description: |
        Erfordert Permission `procedure_template_edit`. Gleicher Body wie beim
        Upload. Erhoeht `minor_version`. **Antwortet mit 201**, nicht 200
        (schreibt eine neue, unveraenderliche Versionszeile).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                  maxLength: 255
                procedure_id:
                  type: string
                  maxLength: 36
      responses:
        '201':
          description: Neue Version erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedure-templates/{id}/approve:
    post:
      tags: [procedure]
      summary: Vorlage freigeben (Approval)
      description: |
        Erfordert Permission `procedure_template_edit`. Kein Body. Erhoeht
        `major_version` (+1, `minor_version` → 0) und setzt `released="approval"`.
        **Antwortet mit 201** (schreibt eine neue Versionszeile).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '201':
          description: Freigegeben
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Template approved."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedure-templates/{id}/release:
    post:
      tags: [procedure]
      summary: Freigegebene Vorlage veroeffentlichen (Release)
      description: |
        Erfordert Permission `procedure_template_edit`. Kein Body. Setzt
        `released="release"` IN DERSELBEN Zeile (keine neue Version, anders als
        `/approve`). Schlaegt fehl, wenn die Vorlage aktuell nicht
        `released="approval"` ist.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '200':
          description: Veroeffentlicht
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Template released."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Vorlage ist aktuell nicht im Status `approval`
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "Only approved template versions can be released."

  /api/v2/procedure-templates/{id}/assign:
    post:
      tags: [procedure]
      summary: Vorlage einer Prozedur zuordnen
      description: |
        Erfordert Permission `procedure_template_edit`. `procedure_id` ist
        Pflicht, wird aber NUR per `findOrFail` im Controller geprueft (kein
        `exists` im FormRequest) — bei unbekannter ID also Standard-404
        "Procedure not found.", kein 422.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [procedure_id]
              properties:
                procedure_id:
                  type: string
                  maxLength: 36
            example:
              procedure_id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '200':
          description: Zugeordnet
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Template assigned to procedure."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedure-templates/{id}/unassign:
    post:
      tags: [procedure]
      summary: Vorlagen-Zuordnung zu einer Prozedur aufheben
      description: Erfordert Permission `procedure_template_edit`. Kein Body.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '200':
          description: Zuordnung entfernt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Template unassigned from procedure."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedure-templates/{id}/steps:
    get:
      tags: [procedure]
      summary: Pruefschritte einer Vorlage abrufen (aus der Datei geparst)
      description: |
        Erfordert Permission `procedure_template_view`. Liest die JSON-Datei der
        Vorlage und parst sie. Schritte sind Datei-Inhalt, keine DB-Zeilen —
        daher KEIN `id`/`type`-Umschlag wie bei Kalibrierungs-Pruefschritten.
        422 bei fehlender/kaputter/inkompatibler Datei (mehrere moegliche
        `detail`-Texte: "The template file could not be found on disk.",
        "...could not be read.", "...is not valid JSON.",
        "...is not a calserver procedure document.",
        "Unsupported procedure document version...").
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '200':
          description: Geparste Pruefschritte
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/TestStepAttributes'
                        - type: object
                          properties:
                            group_defaults:
                              type: object
                              nullable: true
                              additionalProperties: true
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - test_type: "Header"
                    description: "DC Voltage"
                    group_defaults:
                      config: "NUMERIC,UUT INDICATED"
                      test_value_u: "V"
                      tolerance: "0.05% 5U"
                      uncertainty: 0.02
                      k: 2
                      decision_rule: "ILAC-G8 w=U"
                  - test_type: "Manual Test"
                    description: "600 mV range"
                    test_value: 0.5
                    test_value_u: "V"
                    range: 0.6
                meta:
                  total: 2
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Vorlagendatei fehlt, ist beschaedigt oder inkompatibel
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The template file could not be found on disk."

    put:
      tags: [procedure]
      summary: Pruefschritte einer Vorlage als neue Version speichern
      description: |
        Erfordert Permission `procedure_template_edit`. Ersetzt den kompletten
        Schrittsatz der Datei. **Antwortet mit 201** (schreibt eine neue
        Minor-Version, kein In-Place-Update).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [steps]
              properties:
                steps:
                  type: array
                  items:
                    $ref: '#/components/schemas/TestStepInput'
            example:
              steps:
                - test_type: "Header"
                  description: "DC Voltage"
                  group_defaults:
                    test_value_u: "V"
                    tolerance: "0.05% 5U"
                - test_type: "Manual Test"
                  description: "600 mV range"
                  test_value: 0.5
                  range: 0.6
      responses:
        '201':
          description: Neue Version mit den uebergebenen Schritten gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
                      steps:
                        type: integer
              example:
                meta:
                  message: "Template steps saved as a new version."
                  steps: 2
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedures/{procedureId}/unassigned-templates:
    get:
      tags: [procedure]
      summary: Noch nicht zugeordnete Vorlagen fuer eine Prozedur auflisten
      description: Erfordert Permission `procedure_template_view`. Nicht paginiert.
      parameters:
        - name: procedureId
          in: path
          required: true
          description: uID der Prozedur
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '200':
          description: Nicht zugeordnete Vorlagen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedures/{procedureId}/save-as-template:
    post:
      tags: [procedure]
      summary: Prozedur als leere Vorlage speichern
      description: |
        Erfordert Permission `procedure_template_edit` (NICHT `procedure_edit`!).
        Kein Body. Legt eine neue, schrittleere Vorlage an (Version 0.1).
      parameters:
        - name: procedureId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '201':
          description: Leere Vorlage erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Procedure saved as template."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedures/{procedureId}/manual-template:
    post:
      tags: [procedure]
      summary: Vorlage manuell aus Pruefschritten erstellen
      description: |
        Erfordert Permission `procedure_template_edit`. Erstellt direkt eine
        Vorlagen-Datei aus den uebergebenen Schritten (statt eines Datei-Uploads).
      parameters:
        - name: procedureId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, steps]
              properties:
                name:
                  type: string
                  maxLength: 255
                steps:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: '#/components/schemas/TestStepInput'
            example:
              name: "DMM Kalibrierverfahren (manuell)"
              steps:
                - test_type: "Header"
                  description: "DC Voltage"
                  group_defaults:
                    test_value_u: "V"
                    tolerance: "0.05% 5U"
                - test_type: "Manual Test"
                  description: "600 mV range"
                  test_value: 0.5
                  range: 0.6
      responses:
        '201':
          description: Vorlage erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
                      steps:
                        type: integer
              example:
                meta:
                  message: "Template created manually."
                  steps: 2
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  # ──────────────────────────────────────────────
  # SPECIFICATIONS
  # ──────────────────────────────────────────────
  /api/v2/specifications:
    get:
      tags: [procedure]
      summary: Spezifikationen auflisten
      description: |
        Erfordert Permission `specification_view`. Eine Spezifikation je
        Geraetetyp, fuer ein Geraet nur bei geraetebezogenen Daten (ADR
        2026-11-02 „Eine Spezifikation mit Optionen“). Jede Zeile traegt den
        Kopf, die Kurzform der geltenden Revision und des Entwurfs sowie die
        Zahl der Optionen und Zeilen (aus der geltenden Revision, sonst aus dem
        Entwurf).

        **Abweichende Pagination:** flaches `per_page` (1–500, geklemmt) statt
        `page[size]`, Laravels Standard-`page` statt `page[number]`. Ohne
        `per_page` werden ALLE Treffer zurueckgegeben (nur `meta.total`). Die
        Filter sind Exakt-/Teilstring-Filter, NICHT die `like:`/`ne:`-Konvention
        der uebrigen API.
      parameters:
        - name: "filter.type_id"
          in: query
          schema:
            type: string
        - name: "filter.inventory_id"
          in: query
          schema:
            type: string
            format: uuid
        - name: "filter.scope"
          in: query
          description: "`type` (Spezifikationen an Geraetetypen) oder `device` (an Geraeten)"
          schema:
            type: string
            enum: [type, device]
        - name: "filter.status"
          in: query
          description: "`released` (hat eine geltende Revision), `draft` (hat einen Entwurf), `none` (nie freigegeben)"
          schema:
            type: string
            enum: [released, draft, none]
        - name: "filter.search"
          in: query
          description: Teilstring-Suche im Namen
          schema:
            type: string
        - name: per_page
          in: query
          description: "1–500. Ohne Angabe: alle Treffer, keine Seitengrenzen."
          schema:
            type: integer
            minimum: 1
            maximum: 500
      responses:
        '200':
          description: Spezifikationsliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SpecificationListItem'
                  meta:
                    type: object
                    description: "`page`/`per_page`/`last_page` nur vorhanden, wenn `per_page` angegeben wurde."
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [procedure]
      summary: Spezifikation mit erstem Entwurf anlegen
      description: |
        Erfordert Permission `specification_edit`. Legt die Spezifikation an
        einem Typ (`type_id`) oder Geraet (`inventory_id`) an, mit einem
        Entwurf als Revision 1. Ohne `content.options` entsteht die Option
        `manufacturer` („Hersteller“) als Standardoption. Gibt es am Typ oder
        Geraet schon eine Spezifikation, antwortet der Server mit 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SpecificationCreateInput'
            example:
              name: "Pt100-Widerstandsthermometer"
              type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
      responses:
        '201':
          description: Spezifikation angelegt, Antwort wie `GET /specifications/{id}`
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/SpecificationError'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/specifications/schema:
    get:
      tags: [procedure]
      summary: Spezifikations-Import-/Export-Format beschreiben
      description: |
        Erfordert Permission `specification_view`. Selbstbeschreibung des
        Formats `calserver.specification` Version 2 fuer `POST .../import` und
        `GET .../export`: Optionsarten, Zeilenarten und -felder,
        Funktionskatalog, Regeln fuer Zuordnung und Freigabe, Importmodi,
        Beispiel. `meta.four_eyes` sagt, ob das Labor die Vier-Augen-Freigabe
        eingeschaltet hat.
      responses:
        '200':
          description: Format-Beschreibung
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationSchemaResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
                      four_eyes:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/specifications/export:
    get:
      tags: [procedure]
      summary: Mehrere Spezifikationen als JSON exportieren
      description: |
        Erfordert Permission `specification_view`. Genau EINE Selektion angeben:
        `ids` (kommasepariert) ODER `filter[type_id]` ODER `filter[inventory_id]`
        ODER `all=1` (Vorrang in dieser Reihenfolge). Exportiert wird je
        Spezifikation die geltende Revision, mit `draft=1` der Entwurf.
        Kundenoptionen nur mit `include_customer_options=1`. Datei-Download im
        Format `calserver.specification` Version 2.
      parameters:
        - name: ids
          in: query
          description: Kommaseparierte Liste von Spezifikations-UUIDs
          schema:
            type: string
        - name: "filter[type_id]"
          in: query
          schema:
            type: string
        - name: "filter[inventory_id]"
          in: query
          schema:
            type: string
        - name: all
          in: query
          schema:
            type: boolean
        - name: draft
          in: query
          description: Entwurf statt geltender Revision exportieren
          schema:
            type: boolean
        - name: include_customer_options
          in: query
          schema:
            type: boolean
      responses:
        '200':
          description: JSON-Datei-Download
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SpecificationPayload'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Keine Selektion angegeben
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /api/v2/specifications/{id}:
    get:
      tags: [procedure]
      summary: Spezifikation mit Revisionen, geltendem Inhalt und Entwurf abrufen
      description: |
        Erfordert Permission `specification_view`. Kopf, alle Revisionen in
        Kurzform, Optionen und Zeilen der geltenden Revision (`current`) und
        des Entwurfs (`draft`) sowie die Befunde der Freigabepruefung fuer den
        Entwurf (`draft_issues`).
      parameters:
        - $ref: '#/components/parameters/specificationId'
      responses:
        '200':
          description: Spezifikation
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [procedure]
      summary: Nie freigegebene Spezifikation loeschen
      description: |
        Erfordert Permission `specification_delete`. Nur eine Spezifikation
        ohne freigegebene oder zurueckgezogene Revision; sonst 409. Eine
        freigegebene Revision wird zurueckgezogen, nie geloescht.
      parameters:
        - $ref: '#/components/parameters/specificationId'
      responses:
        '204':
          description: Geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/SpecificationError'

  /api/v2/specifications/{id}/export:
    get:
      tags: [procedure]
      summary: Eine Spezifikation oder eine Revision als JSON exportieren
      description: |
        Erfordert Permission `specification_view`. Ohne `revision` die
        geltende Revision (mit `draft=1` den Entwurf), mit `revision` (Nummer
        oder UUID) genau diese Revision. Kundenoptionen nur mit
        `include_customer_options=1`. Datei-Download, Dateiname
        `<slug>.json` bzw. `<slug>-rev<n>.json`.
      parameters:
        - $ref: '#/components/parameters/specificationId'
        - name: revision
          in: query
          schema:
            type: string
        - name: draft
          in: query
          schema:
            type: boolean
        - name: include_customer_options
          in: query
          schema:
            type: boolean
      responses:
        '200':
          description: JSON-Datei-Download
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SpecificationPayload'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/specifications/{id}/draft:
    post:
      tags: [procedure]
      summary: Entwurf der naechsten Revision beginnen
      description: |
        Erfordert Permission `specification_edit`. Kopiert die geltende
        Revision in einen neuen Entwurf. Gibt es schon einen Entwurf, bleibt er
        unveraendert (hoechstens ein Entwurf je Spezifikation).
      parameters:
        - $ref: '#/components/parameters/specificationId'
      responses:
        '200':
          description: Spezifikation mit Entwurf
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [procedure]
      summary: Inhalt des Entwurfs ersetzen
      description: |
        Erfordert Permission `specification_edit`. Ersetzt Optionen und Zeilen
        des Entwurfs vollstaendig (kein Partial Update) und legt einen Entwurf
        an, wenn es keinen gibt. `meta.issues` sind die Befunde der
        Freigabepruefung; gespeichert wird auch mit Befunden, freigegeben erst
        ohne Fehler.
      parameters:
        - $ref: '#/components/parameters/specificationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SpecificationContent'
            example:
              name: "Pt100-Widerstandsthermometer"
              default_option: class-b
              options:
                - { key: class-a, name: "Klasse A", kind: standard, note: "DIN EN 60751" }
                - { key: class-b, name: "Klasse B", kind: standard, note: "DIN EN 60751" }
              rows:
                - { key: band, kind: band, function: temp, unit: "°C", range_start: -50, range_end: 250, tolerances: { class-a: "0.15U 0.2%", class-b: "0.3U 0.5%" } }
                - { key: p0, kind: point, function: temp, unit: "°C", value: 0, tolerances: { class-a: "0.15U", class-b: "0.3U" } }
      responses:
        '200':
          description: Entwurf gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationDetail'
                  meta:
                    type: object
                    properties:
                      issues:
                        type: array
                        items:
                          $ref: '#/components/schemas/SpecificationIssue'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [procedure]
      summary: Entwurf verwerfen
      description: |
        Erfordert Permission `specification_edit`. Hat die Spezifikation keine
        freigegebene Revision, verschwindet sie mit dem Entwurf; dann ist
        `data` null und `meta.deleted` true.
      parameters:
        - $ref: '#/components/parameters/specificationId'
      responses:
        '200':
          description: Entwurf verworfen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    allOf:
                      - $ref: '#/components/schemas/SpecificationDetail'
                    nullable: true
                  meta:
                    type: object
                    properties:
                      deleted:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/specifications/{id}/release:
    post:
      tags: [procedure]
      summary: Entwurf freigeben
      description: |
        Erfordert Permission `specification_edit`. Prueft den Inhalt (mindestens
        eine Option, Standardoption vorhanden, lesbare Toleranzen, keine zwei
        gleichen Punktzeilen in einer Option) und friert die Revision mit
        Person, Zeitpunkt und Inhalts-Hash ein. Mit eingeschalteter
        Vier-Augen-Freigabe darf nur freigeben, wer den Entwurf nicht zuletzt
        bearbeitet hat. Scheitert die Pruefung, nennt der Fehler die Befunde in
        `error.details.issues`.
      parameters:
        - $ref: '#/components/parameters/specificationId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                change_note:
                  type: string
                  nullable: true
                  description: Was sich gegenueber der vorigen Revision aendert
      responses:
        '200':
          description: Freigegeben
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/SpecificationError'
        '422':
          $ref: '#/components/responses/SpecificationError'

  /api/v2/specifications/{id}/revisions:
    get:
      tags: [procedure]
      summary: Revisionen einer Spezifikation auflisten
      description: Erfordert Permission `specification_view`. Neueste zuerst.
      parameters:
        - $ref: '#/components/parameters/specificationId'
      responses:
        '200':
          description: Revisionen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SpecificationRevision'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/specifications/{id}/revisions/{revision}:
    get:
      tags: [procedure]
      summary: Eine Revision mit Optionen und Zeilen abrufen
      description: Erfordert Permission `specification_view`. `{revision}` ist die Nummer oder die UUID.
      parameters:
        - $ref: '#/components/parameters/specificationId'
        - $ref: '#/components/parameters/specificationRevision'
      responses:
        '200':
          description: Revision
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationRevisionContent'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/specifications/{id}/revisions/{revision}/diff:
    get:
      tags: [procedure]
      summary: Revision mit einer anderen vergleichen
      description: |
        Erfordert Permission `specification_view`. Ohne `against` gegen die
        vorige nicht als Entwurf gefuehrte Revision. Liefert Aenderungen am
        Kopf, an Optionen und Zeilen und je Option die geaenderten Toleranzen.
      parameters:
        - $ref: '#/components/parameters/specificationId'
        - $ref: '#/components/parameters/specificationRevision'
        - name: against
          in: query
          description: Nummer oder UUID der Vergleichsrevision
          schema:
            type: string
      responses:
        '200':
          description: Vergleich
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationDiff'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/specifications/{id}/revisions/{revision}/band-preview:
    get:
      tags: [procedure]
      summary: Toleranzbaender einer Revision je Option berechnen
      description: |
        Erfordert Permission `specification_view`. Rechnet je Band- oder
        Punktzeile und Option die Grenzen an einigen Stuetzstellen, fuer die
        Vorschau im Editor. `missing_references` nennt Bezuege, die fehlen
        (etwa ein Bereich fuer `/`-Terme).
      parameters:
        - $ref: '#/components/parameters/specificationId'
        - $ref: '#/components/parameters/specificationRevision'
      responses:
        '200':
          description: Baender
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/specifications/{id}/revisions/{revision}/withdraw:
    post:
      tags: [procedure]
      summary: Freigegebene Revision zurueckziehen
      description: |
        Erfordert Permission `specification_edit`. Mit Grund. Eine
        zurueckgezogene Revision gilt fuer neue Kalibrierungen nicht mehr;
        Kalibrierungen, die mit ihr geladen wurden, behalten sie.
      parameters:
        - $ref: '#/components/parameters/specificationId'
        - $ref: '#/components/parameters/specificationRevision'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Zurueckgezogen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SpecificationDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/SpecificationError'
        '422':
          $ref: '#/components/responses/SpecificationError'

  /api/v2/specifications/import:
    post:
      tags: [procedure]
      summary: Spezifikationen importieren
      description: |
        Erfordert Permission `specification_edit`. Entweder `file` (Upload)
        ODER `payload` (JSON inline). Format `calserver.specification`
        Version 2; Version 1 (ein Blatt je Variante) wird beim Import
        umgesetzt, ein Blatt wird eine Option (`meta.converted_from_v1`).
        Importiertes landet immer im Entwurf; `release=true` gibt danach frei,
        aber nur fuer Quellen ohne Pflicht zur Durchsicht (nicht fuer
        `extracted`, `metcal`, `acc`) und nur ohne Fehlerbefunde.

        Modi bei vorhandener Spezifikation am Ziel: `create` (Standard, scheitert),
        `skip` (unberuehrt lassen), `replace` (Inhalt in den Entwurf schreiben),
        `merge` (Optionen der Nutzlast in den Entwurf aufnehmen, passende Zeilen
        bekommen nur die neue Toleranz). `dry_run=true` schreibt nichts und
        liefert `data:[]` mit gefuellten `meta.entries`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                payload:
                  $ref: '#/components/schemas/SpecificationPayload'
                type_id:
                  type: string
                  maxLength: 80
                  nullable: true
                inventory_id:
                  type: string
                  format: uuid
                  nullable: true
                mode:
                  type: string
                  enum: [create, skip, replace, merge]
                dry_run:
                  type: boolean
                release:
                  type: boolean
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                type_id:
                  type: string
                  maxLength: 80
                inventory_id:
                  type: string
                  format: uuid
                mode:
                  type: string
                  enum: [create, skip, replace, merge]
                dry_run:
                  type: boolean
                release:
                  type: boolean
      responses:
        '200':
          description: Importergebnis
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SpecificationListItem'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      created:
                        type: integer
                      replaced:
                        type: integer
                      skipped:
                        type: integer
                      released:
                        type: integer
                      dry_run:
                        type: boolean
                      converted_from_v1:
                        type: boolean
                      entries:
                        type: array
                        items:
                          type: object
                          properties:
                            name:
                              type: string
                            action:
                              type: string
                              enum: [created, replaced, merged, skipped]
                            specification_id:
                              type: string
                              nullable: true
                            revision:
                              type: integer
                              nullable: true
                            released:
                              type: boolean
                            options:
                              type: integer
                            rows:
                              type: integer
                            issues:
                              type: array
                              items:
                                $ref: '#/components/schemas/SpecificationIssue'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/SpecificationError'
        '422':
          description: |
            Nutzlast entspricht nicht dem Format (`detail`: "The import payload
            does not match the specification format.", Feldfehler unter
            `errors`) oder die Datei ist kein gueltiges JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/specifications/from-metcal:
    post:
      tags: [procedure]
      summary: Messpunkte einer MET/CAL-Prozedur als Option uebernehmen
      description: |
        Erfordert Permission `specification_edit`. Die Prozedur laeuft durch
        denselben Weg wie der Import im Planeditor (lesen, nach calScript
        uebersetzen, kompilieren). Ihre Messpunkte werden die Option
        `metcal-<prozedur>` (Art `usage`) im Entwurf der Spezifikation des
        Geraetetyps; fehlt die Spezifikation, entsteht sie. Jede Zeile traegt
        ihre Fundstelle (`source_reference`), ein Pruefpunkt wird eine
        Punktzeile, auch ein negativer.

        Ist die Option in der geltenden Revision schon freigegeben, schreibt
        der Endpunkt ohne `on_released` nichts und antwortet mit 409.
        `on_released=new_version` ersetzt die Option im Entwurf der naechsten
        Revision (bis zu dessen Freigabe gilt die freigegebene),
        `on_released=new_name` legt die Messpunkte als weitere Option unter
        `name` an.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [type_id]
              properties:
                file:
                  type: string
                  format: binary
                  description: Die Prozedur als Textdatei (hoechstens 2 MB); alternativ `text`.
                text:
                  type: string
                  description: Die Prozedur als Text; alternativ `file`.
                file_name:
                  type: string
                  maxLength: 255
                  nullable: true
                  description: Dateiname fuer die Fundstelle, wenn die Prozedur als `text` kommt.
                type_id:
                  type: string
                  maxLength: 80
                camera:
                  type: boolean
                on_released:
                  type: string
                  nullable: true
                  enum: [new_version, new_name, null]
                name:
                  type: string
                  maxLength: 255
                  nullable: true
                  description: Name der weiteren Option; Pflicht bei `on_released=new_name`.
      responses:
        '200':
          description: Option in einer vorhandenen Spezifikation aufgenommen oder ersetzt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetCalSpecificationResult'
        '201':
          description: Spezifikation neu angelegt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetCalSpecificationResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Die Option dieser Prozedur ist schon freigegeben; nichts geschrieben.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                        example: 409
                      title:
                        type: string
                      detail:
                        type: string
                  data:
                    type: object
                    properties:
                      action:
                        type: string
                        enum: [conflict]
                      existing:
                        type: object
                        properties:
                          id:
                            type: string
                            format: uuid
                          name:
                            type: string
                          option_key:
                            type: string
                          option_name:
                            type: string
                            nullable: true
                          revision:
                            type: integer
                          released_at:
                            type: string
                            format: date-time
                            nullable: true
                      rows_count:
                        type: integer
        '422':
          description: |
            Prozedur nicht lesbar oder nicht uebersetzbar (`line` nennt die
            Zeile), oder sie enthaelt keinen Messpunkt mit Toleranz.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/specifications/{id}/generate-procedure:
    post:
      tags: [procedure]
      summary: Prozedur-Vorlage aus einer Option generieren
      description: |
        **Erfordert `procedure_edit`** (NICHT `specification_edit`!). Baut aus
        den Punktzeilen einer Option (`option_key`, sonst die Standardoption)
        einen Pruefplan am Geraetetyp; ohne Punktzeilen je Band einen Punkt am
        Bandende. Der Plan beschreibt nur das Verfahren und traegt keine
        Toleranzen: sie kommen beim Laden aus der Option, die die Kalibrierung
        gewaehlt hat.
      parameters:
        - $ref: '#/components/parameters/specificationId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                option_key:
                  type: string
                  nullable: true
      responses:
        '200':
          description: Vorlage generiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
        '201':
          description: Vorlage generiert (neu angelegt)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/SpecificationError'

  /api/v2/inventories/{mtag}/specifications:
    get:
      tags: [procedure]
      summary: Spezifikationsstand eines Geraets
      description: |
        Erfordert Permission `specification_view`. Die Spezifikation des Typs
        und die des Geraets (falls vorhanden), welche gilt (`effective`: eine
        Geraetespezifikation mit freigegebener Revision ersetzt die des Typs
        ganz), die Optionen der geltenden Revision (Kundenoptionen nur fuer
        Geraete dieses Kunden) und die Standardoption des Geraets.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Stand
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventorySpecificationState'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/specification-default:
    put:
      tags: [procedure]
      summary: Standardoption eines Geraets setzen
      description: |
        Erfordert Permission `specification_edit`. Vorbelegung der Option fuer
        die Kalibrierungen des Geraets; fuer ein Normal die Option, mit der
        seine Genauigkeit gerechnet wird. Ein leerer `option_key` entfernt die
        Standardoption des Geraets.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                option_key:
                  type: string
                  nullable: true
      responses:
        '200':
          description: Neuer Stand
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventorySpecificationState'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/SpecificationError'
        '422':
          $ref: '#/components/responses/SpecificationError'

  /api/v2/calibrations/{ctag}/specification:
    get:
      tags: [calibration]
      summary: Gewaehlte Option einer Kalibrierung
      description: |
        Erfordert Permission `calibration_view`. Spezifikation und geltende
        Revision des Geraets, die waehlbaren Optionen, die Wahl (falls
        getroffen) und die Vorauswahl mit Herkunft: Standardoption des Geraets,
        dann eine Option fuer den Kunden des Geraets, dann die Standardoption
        der Spezifikation; mit nur einer Option diese. `reload_required` sagt,
        dass der Plan nach einer geaenderten Wahl neu geladen werden muss.
      parameters:
        - $ref: '#/components/parameters/ctag'
      responses:
        '200':
          description: Wahl
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationSpecificationState'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [calibration]
      summary: Option fuer eine Kalibrierung waehlen
      description: |
        Erfordert Permission `calibration_edit`. Genau eine Option je
        Kalibrierung; gewaehlt wird vor dem Laden des Plans. Eine Kundenoption
        passt nur zu Geraeten dieses Kunden.
      parameters:
        - $ref: '#/components/parameters/ctag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [option_key]
              properties:
                option_key:
                  type: string
            example:
              option_key: class-a
      responses:
        '200':
          description: Neue Wahl
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationSpecificationState'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/SpecificationError'
        '422':
          $ref: '#/components/responses/SpecificationError'

  # ──────────────────────────────────────────────
  # SAMMELLAUF (ADR 2026-11-02 „Sammellauf, Stabilitaet und Referenzablesung“)
  # ──────────────────────────────────────────────
  /api/v2/procedure-batches:
    get:
      tags: [procedure]
      summary: Sammellaeufe auflisten
      description: |
        Erfordert Permission `workstation_view`. Hoechstens 50, neueste zuerst.
        `status=open` liefert offene und laufende.
      parameters:
        - name: workstation_id
          in: query
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          schema:
            type: string
            enum: [open, running, done, aborted]
      responses:
        '200':
          description: Sammellaeufe
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProcedureBatch'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      api_version:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [procedure]
      summary: Sammellauf anlegen
      description: |
        Erfordert Permission `workstation_run`. Zwei bis 40 Kalibrierungen
        derselben Vorlagenversion (Schritte nach dem Laden unveraendert) an
        einem Messplatz; jedes Mitglied bekommt einen Kanal am Lesegeraet.
        Der Plan muss sich fuer den Sammellauf eignen (jeder Messpunkt liest
        den Pruefling ueber ein Geraet, der Pruefling ist nie Quelle, eine
        Referenz liest ein Geraet); sonst 422 mit Befunden.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workstation_id, reader_device_id, reader_role, members]
              properties:
                workstation_id:
                  type: string
                  format: uuid
                reader_device_id:
                  type: string
                  description: Geraet des Messplatzes, das die Prueflinge liest (Faehigkeit `channels`)
                reader_role:
                  type: string
                  description: Rolle im Plan, ueber die gelesen wird (meist `uut`)
                reference_channel:
                  type: string
                  nullable: true
                  description: Kanal der Referenz, wenn sie am selben Lesegeraet haengt
                members:
                  type: array
                  minItems: 2
                  maxItems: 40
                  items:
                    type: object
                    required: [calibration_id, channel]
                    properties:
                      calibration_id:
                        type: string
                      channel:
                        type: string
      responses:
        '201':
          description: Angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureBatch'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/ProcedureBatchError'
        '422':
          $ref: '#/components/responses/ProcedureBatchError'

  /api/v2/procedure-batches/{id}:
    get:
      tags: [procedure]
      summary: Sammellauf mit dem Stand jedes Mitglieds abrufen
      description: Erfordert Permission `workstation_view`.
      parameters:
        - $ref: '#/components/parameters/procedureBatchId'
      responses:
        '200':
          description: Sammellauf
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureBatch'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedure-batches/{id}/bindings:
    get:
      tags: [procedure]
      summary: Vorschau der Bindung eines Sammellaufs
      description: |
        Erfordert Permission `workstation_view`. Was der Runner bindet
        (`calserver-runner.batch-bindings`): gemeinsame Rollen und Geraete wie
        im Einzellauf, je Mitglied das Lesegeraet mit Kanal, nicht gebundene
        Rollen unter `unbound`.
      parameters:
        - $ref: '#/components/parameters/procedureBatchId'
      responses:
        '200':
          description: Bindungsdokument
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedure-batches/{id}/preflight:
    post:
      tags: [procedure]
      summary: Gemeinsamen Vorlauf und Eingangsentscheide schreiben
      description: |
        Erfordert Permission `workstation_run`. Umgebung, Normale und Rollen
        gelten fuer alle Mitglieder (Felder wie beim Vorlauf eines
        Einzellaufs); der Eingangsentscheid gehoert dem einzelnen Pruefling
        und steht je Kalibrierung unter `as_found`.
      parameters:
        - $ref: '#/components/parameters/procedureBatchId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [as_found]
              properties:
                temperature:
                  type: number
                  nullable: true
                humidity:
                  type: number
                  nullable: true
                pressure:
                  type: number
                  nullable: true
                standards:
                  type: array
                  items:
                    type: string
                standard_roles:
                  type: object
                  additionalProperties:
                    type: string
                    nullable: true
                standards_waiver_reason:
                  type: string
                  nullable: true
                standards_validity_override_reason:
                  type: string
                  nullable: true
                as_found:
                  type: object
                  description: Kalibrierungs-ID zu Eingangsentscheid
                  additionalProperties:
                    type: string
      responses:
        '200':
          description: Vorlauf geschrieben
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureBatch'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/ProcedureBatchError'
        '422':
          $ref: '#/components/responses/ValidationError'
        '423':
          $ref: '#/components/responses/ProcedureBatchError'

  /api/v2/procedure-batches/{id}/dispatch:
    post:
      tags: [procedure]
      summary: Sammellauf an den Messplatz senden
      description: |
        Erfordert Permission `workstation_run`. Legt ein Kommando `run` mit
        `batch_id` fuer den Messplatz an. `complete=true` schliesst am Ende
        jeden Lauf ab, wie im Einzellauf.
      parameters:
        - $ref: '#/components/parameters/procedureBatchId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                complete:
                  type: boolean
      responses:
        '201':
          description: Kommando angelegt; `meta.batch` ist der Sammellauf
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: true
                  meta:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ProcedureBatch'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/ProcedureBatchError'
        '423':
          $ref: '#/components/responses/ProcedureBatchError'

  /api/v2/procedure-batches/{id}/abort:
    post:
      tags: [procedure]
      summary: Sammellauf abbrechen
      description: |
        Erfordert Permission `workstation_run`. Die Laeufe der Mitglieder
        bleiben offen, erfasste Werte bleiben.
      parameters:
        - $ref: '#/components/parameters/procedureBatchId'
      responses:
        '200':
          description: Abgebrochen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureBatch'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '423':
          $ref: '#/components/responses/ProcedureBatchError'

  /api/v2/procedure-batches/{id}/checkout:
    get:
      tags: [procedure]
      summary: Auscheck-Stand eines Sammellaufs und Pruefung „autark-faehig“
      description: |
        Erfordert Permission `workstation_view`. `data` sind die aktiven
        Auscheck-Eintraege der Mitglieder (leer, wenn nicht ausgecheckt).
        `meta.check` prueft, ob der Messplatz des Sammellaufs ihn ohne
        calServer zu Ende bringen kann: wie im Einzellauf (`no_run`,
        `preflight_open`, `judgement_branch`, `role_unbound`, `checked_out`),
        dazu `batch_not_open` (schon gesendet, ausgecheckt, fertig oder
        abgebrochen) und `member_unbound` (ein Geraetepunkt liest den
        Pruefling, aber er hat weder Kanal noch eigenes Geraet). Befunde
        eines Mitglieds tragen `calibration_id`.
        ADR 2026-11-08 „Sammellauf autark am Messplatz“.
      parameters:
        - $ref: '#/components/parameters/procedureBatchId'
      responses:
        '200':
          description: Auscheck-Stand
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  meta:
                    type: object
                    additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [procedure]
      summary: Sammellauf an seinen Messplatz auschecken (autark)
      description: |
        Erfordert Permission `workstation_run`. Je Mitglied ein
        Auscheck-Eintrag mit derselben `batch_id`; der Sammellauf steht
        danach auf `running`. Senden, Vorlauf und Abbrechen antworten bis
        zur Rueckgabe mit 423 (`code: batch_checked_out`), Erfassen und
        Abschliessen der Mitglieder mit 423 (`code: checked_out`). Gibt der
        Messplatz das letzte Mitglied zurueck, steht der Sammellauf auf
        `done`. 422 mit den Befunden, wenn er nicht autark messbar ist.
      parameters:
        - $ref: '#/components/parameters/procedureBatchId'
      responses:
        '201':
          description: Ausgecheckt; `data` sind die Eintraege je Mitglied
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [procedure]
      summary: Ausgecheckten Sammellauf ganz zurueckholen
      description: |
        Erfordert Permission `workstation_run`. Holt alle Mitglieder zurueck
        (auch `DELETE /calibrations/{ctag}/run/checkout` eines Mitglieds tut
        das); der Sammellauf steht wieder auf `open`. 404, wenn er nicht
        ausgecheckt ist.
      parameters:
        - $ref: '#/components/parameters/procedureBatchId'
      responses:
        '200':
          description: Zurueckgeholt (`state` `cancelled`)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # STANDARD-PROCEDURE-ZUORDNUNGEN (Geraetetyp)
  # ──────────────────────────────────────────────
  /api/v2/device-types/{tID}/standard-procedure:
    get:
      tags: [procedure]
      summary: Effektive Standardprozedur eines Geraetetyps abrufen
      description: |
        Erfordert Permission `types_view`. Liefert die aktuelle Default-
        Zuordnung oder `null`, wenn keine existiert.
      parameters:
        - $ref: '#/components/parameters/tID'
      responses:
        '200':
          description: Effektive Standardprozedur (oder `null`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    nullable: true
                    allOf:
                      - $ref: '#/components/schemas/StandardProcedureResource'
              examples:
                zugeordnet:
                  summary: Default-Prozedur vorhanden
                  value:
                    data:
                      id: "a7b8c9d0-3456-4e5f-9012-c3d4e5f6a7b8"
                      type: standard-procedure
                      attributes:
                        procedure_uID: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                        assignable_table: types
                        assignable_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                        is_default: true
                        procedure:
                          id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                          procedure_name: "Drehmomentschluessel — Vergleichskalibrierung"
                        created_by: null
                        created_at: "2026-07-01T10:00:00.000000Z"
                        updated_at: "2026-07-01T10:00:00.000000Z"
                keine_zuordnung:
                  summary: Kein Default zugeordnet
                  value:
                    data: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/device-types/{tID}/standard-procedures:
    get:
      tags: [procedure]
      summary: Alle Standardprozedur-Zuordnungen eines Geraetetyps auflisten
      description: |
        Erfordert Permission `types_view`. Alle Rohzuordnungen, Default zuerst.
        Nicht paginiert.
      parameters:
        - $ref: '#/components/parameters/tID'
      responses:
        '200':
          description: Zuordnungen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/StandardProcedureResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [procedure]
      summary: Prozedur einem Geraetetyp als Standard zuordnen
      description: |
        Erfordert Permission `types_edit`. Idempotent bei erneuter Zuordnung.
        Schreibt zugleich `procedure_types`, die Zuordnung erscheint also auch
        auf der Prozedurkarte unter "Geraetetypen".
        Die erste Zuordnung wird automatisch Default; `is_default:true`
        befoerdert diese Zuordnung und entfernt den Default-Status aller anderen.
      parameters:
        - $ref: '#/components/parameters/tID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StandardProcedureInput'
            example:
              procedure_id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
              is_default: true
      responses:
        '201':
          description: Zugeordnet
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardProcedureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Standard procedure assigned."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/device-types/{tID}/standard-procedures/{procedureId}/default:
    put:
      tags: [procedure]
      summary: Default-Standardprozedur eines Geraetetyps umschalten
      description: |
        Erfordert Permission `types_edit`. Kein Body. 404 OHNE `detail`-Feld,
        wenn `{procedureId}` diesem Typ nicht zugeordnet ist — weder in
        `standard_procedures` noch in `procedure_types`. Steht die Prozedur nur
        im Pivot (z.B. aus V1), wird die fehlende Standardzuordnung angelegt.
      parameters:
        - $ref: '#/components/parameters/tID'
        - name: procedureId
          in: path
          required: true
          description: uID der Prozedur
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '200':
          description: Default aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardProcedureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Default standard procedure updated."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: "Geraetetyp nicht gefunden ODER `{procedureId}` diesem Typ nicht zugeordnet — in beiden Faellen OHNE `detail`-Feld."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                      title:
                        type: string
              example:
                error:
                  status: 404
                  title: Not Found

  /api/v2/device-types/{tID}/standard-procedures/{procedureId}:
    delete:
      tags: [procedure]
      summary: Standardprozedur-Zuordnung eines Geraetetyps entfernen
      description: |
        Erfordert Permission `types_edit`. Wird der aktuelle Default entfernt,
        wird automatisch die naechstaeltere verbleibende Zuordnung zum neuen
        Default befoerdert. Entfernt beide Seiten der Zuordnung
        (`standard_procedures` und `procedure_types`). Gleiches
        404-ohne-`detail`-Verhalten wie `/default`, wenn nicht zugeordnet.
      parameters:
        - $ref: '#/components/parameters/tID'
        - name: procedureId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '204':
          description: Entfernt
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Geraetetyp nicht gefunden ODER nicht zugeordnet — OHNE `detail`-Feld
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                      title:
                        type: string
              example:
                error:
                  status: 404
                  title: Not Found

  # ──────────────────────────────────────────────
  # STANDARD-PROCEDURE-ZUORDNUNGEN (Inventarobjekt)
  # ──────────────────────────────────────────────
  /api/v2/inventories/{mtag}/standard-procedure:
    get:
      tags: [procedure]
      summary: Effektive Standardprozedur eines Inventarobjekts abrufen
      description: |
        Erfordert Permission `inventory_view`. Liefert die aktuelle Default-
        Zuordnung (eigene ODER vom Geraetetyp geerbt) oder `null`. Bei Treffer
        zusaetzlich `meta.source` (`"device"`|`"type"`) und
        `meta.device_type_id` — beide fehlen bei `data:null`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Effektive Standardprozedur (oder `null`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    nullable: true
                    allOf:
                      - $ref: '#/components/schemas/StandardProcedureResource'
                  meta:
                    type: object
                    description: Nur vorhanden, wenn `data` nicht `null` ist.
                    properties:
                      source:
                        type: string
                        enum: [device, type]
                      device_type_id:
                        type: string
                        format: uuid
              examples:
                vom_geraet_geerbt:
                  summary: Eigene Geraete-Zuordnung
                  value:
                    data:
                      id: "a7b8c9d0-3456-4e5f-9012-c3d4e5f6a7b8"
                      type: standard-procedure
                      attributes:
                        procedure_uID: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                        assignable_table: inventories
                        assignable_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                        is_default: true
                        procedure:
                          id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                          procedure_name: "Drehmomentschluessel — Vergleichskalibrierung"
                        created_by: null
                        created_at: "2026-07-01T10:00:00.000000Z"
                        updated_at: "2026-07-01T10:00:00.000000Z"
                    meta:
                      source: device
                      device_type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                keine_zuordnung:
                  summary: Weder Geraet noch Typ hat eine Zuordnung
                  value:
                    data: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/standard-procedures:
    get:
      tags: [procedure]
      summary: Alle Standardprozedur-Zuordnungen eines Inventarobjekts auflisten
      description: Erfordert Permission `inventory_view`. Alle Rohzuordnungen, Default zuerst. Nicht paginiert.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Zuordnungen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/StandardProcedureResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [procedure]
      summary: Prozedur einem Inventarobjekt als Standard zuordnen
      description: |
        Erfordert Permission `inventory_edit`. Idempotent bei erneuter
        Zuordnung. Erste Zuordnung wird automatisch Default;
        `is_default:true` befoerdert und entfernt den Default-Status aller
        anderen Zuordnungen DIESES Inventarobjekts.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StandardProcedureInput'
            example:
              procedure_id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
              is_default: true
      responses:
        '201':
          description: Zugeordnet
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardProcedureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Standard procedure assigned."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/inventories/{mtag}/standard-procedures/{procedureId}/default:
    put:
      tags: [procedure]
      summary: Default-Standardprozedur eines Inventarobjekts umschalten
      description: |
        Erfordert Permission `inventory_edit`. Kein Body. 404 OHNE `detail`-Feld,
        wenn `{procedureId}` diesem Inventarobjekt nicht zugeordnet ist.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: procedureId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '200':
          description: Default aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardProcedureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Default standard procedure updated."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Inventarobjekt nicht gefunden ODER nicht zugeordnet — OHNE `detail`-Feld
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                      title:
                        type: string
              example:
                error:
                  status: 404
                  title: Not Found

  /api/v2/inventories/{mtag}/standard-procedures/{procedureId}:
    delete:
      tags: [procedure]
      summary: Standardprozedur-Zuordnung eines Inventarobjekts entfernen
      description: |
        Erfordert Permission `inventory_edit`. Wird der aktuelle Default
        entfernt, wird automatisch die naechstaeltere verbleibende Zuordnung
        zum neuen Default befoerdert. Gleiches 404-ohne-`detail`-Verhalten wie
        `/default`, wenn nicht zugeordnet.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: procedureId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '204':
          description: Entfernt
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Inventarobjekt nicht gefunden ODER nicht zugeordnet — OHNE `detail`-Feld
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                      title:
                        type: string
              example:
                error:
                  status: 404
                  title: Not Found

  # ──────────────────────────────────────────────
  # STANDARD-PROCEDURE-ZUORDNUNGEN (prozedurzentrierte Sicht)
  # ──────────────────────────────────────────────
  /api/v2/procedures/{id}/standard-for-types:
    get:
      tags: [procedure]
      summary: Geraetetypen auflisten, fuer die eine Prozedur Standard ist
      description: |
        Erfordert Permission `procedure_view`. Default-Zuordnung zuerst,
        danach aeltestes zuerst. Nicht paginiert.
      parameters:
        - name: id
          in: path
          required: true
          description: uID der Prozedur
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '200':
          description: Zugeordnete Geraetetypen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProcedureStandardTypeResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "a7b8c9d0-3456-4e5f-9012-c3d4e5f6a7b8"
                    type: procedure-standard-type
                    attributes:
                      type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                      type_label: "Fluke 87V"
                      is_default: true
                      created_at: "2026-07-01T10:00:00.000000Z"
                      updated_at: "2026-07-01T10:00:00.000000Z"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [procedure]
      summary: Prozedur als Standard fuer einen Geraetetyp zuordnen
      description: |
        Erfordert Permission `procedure_edit`. `type_id` MUSS in `types.tID`
        existieren (`exists`-Regel — abweichend von `POST .../types`, das nicht
        prueft). `make_default` ist HIER per Default `true` — abweichend von den
        typ-/inventarseitigen Assign-Endpunkten, wo `is_default` standardmaessig
        `false` ist. Antwort-`message` lautet auch bei Erstzuordnung
        "...updated.".
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type_id]
              properties:
                type_id:
                  type: string
                  maxLength: 36
                make_default:
                  type: boolean
                  default: true
            example:
              type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
      responses:
        '201':
          description: Zuordnung angelegt oder aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureStandardTypeResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Standard procedure assignment updated."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedures/{id}/standard-for-types/{typeId}/default:
    put:
      tags: [procedure]
      summary: Diesen Geraetetyp als Default-Zuordnung der Prozedur setzen
      description: |
        Erfordert Permission `procedure_edit`. Kein Body. 404 OHNE `detail`-Feld,
        wenn `{typeId}` dieser Prozedur nicht zugeordnet ist.

        "Zugeordnet" heisst: der Typ steht in `procedure_types` ODER in
        `standard_procedures`. Ein Typ, der nur ueber `POST /procedures/{id}/types`
        (bzw. aus V1) verknuepft ist, laesst sich hiermit zum Default machen —
        die fehlende Standardzuordnung wird dabei angelegt.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
        - name: typeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b3e1c2d3-4567-8901-abcd-ef1234567890"
      responses:
        '200':
          description: Default aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureStandardTypeResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Standard procedure assignment updated."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Prozedur nicht gefunden ODER `{typeId}` nicht zugeordnet — OHNE `detail`-Feld
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                      title:
                        type: string
              example:
                error:
                  status: 404
                  title: Not Found

  /api/v2/procedures/{id}/standard-for-types/{typeId}/template:
    put:
      tags: [procedure]
      summary: Pruefplan dieser Prozedur als Standardpruefplan eines Geraetetyps waehlen
      description: |
        Erfordert Permission `procedure_edit`. Dieselbe Wahl wie
        `PUT /device-types/{tID}/standard-procedures/{procedureId}/template`,
        nur von der Prozedurseite aus (ADR 2026-10-23, Standardpruefplan).
        `template_hash: null` (oder leer) hebt die Wahl auf.

        Steht der Typ nur in `procedure_types`, wird die fehlende
        Standardzuordnung angelegt, ohne die Prozedur zum Default des Typs zu
        machen. 404 OHNE `detail`, wenn der Typ mit der Prozedur gar nicht
        verbunden ist; 422, wenn der Pruefplan nicht zu dieser Prozedur gehoert.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: typeId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_hash]
              properties:
                template_hash:
                  type: string
                  nullable: true
                  maxLength: 20
            example:
              template_hash: "a1b2c3d4e5f6g7h8i9j0"
      responses:
        '200':
          description: Wahl gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedures/{id}/template-usage:
    get:
      tags: [procedure]
      summary: Wo die Pruefplaene einer Prozedur Standardpruefplan sind
      description: |
        Erfordert Permission `procedure_view`. Je Geraetetyp der Prozedur
        (`procedure_types` oder Standardzuordnung) der gewaehlte Pruefplan, und
        je Pruefplan die Zahl der Geraete mit eigener Wahl. Geraete, die den
        Plan ueber ihren Typ erben, zaehlen nicht mit.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Verwendung der Pruefplaene
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      types:
                        type: array
                        items:
                          type: object
                          properties:
                            type_id:
                              type: string
                            type_label:
                              type: string
                            is_standard:
                              type: boolean
                              description: Standardzuordnung vorhanden (nicht nur Pivot)
                            is_default:
                              type: boolean
                              description: Prozedur ist die geltende Standardpruefprozedur des Typs
                            template_hash:
                              type: string
                              nullable: true
                      device_counts:
                        type: object
                        additionalProperties:
                          type: integer
              example:
                data:
                  types:
                    - type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
                      type_label: "Fluke 87V"
                      is_standard: true
                      is_default: true
                      template_hash: "a1b2c3d4e5f6g7h8i9j0"
                  device_counts:
                    a1b2c3d4e5f6g7h8i9j0: 3
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedures/{id}/standard-for-types/{typeId}:
    delete:
      tags: [procedure]
      summary: Typ-Zuordnung einer Prozedur entfernen
      description: |
        Erfordert Permission `procedure_edit`. Gleiches
        404-ohne-`detail`-Verhalten wie `/default`, wenn nicht zugeordnet.
        Entfernt beide Seiten der Zuordnung (`procedure_types` und
        `standard_procedures`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
        - name: typeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b3e1c2d3-4567-8901-abcd-ef1234567890"
      responses:
        '204':
          description: Entfernt
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Prozedur nicht gefunden ODER nicht zugeordnet — OHNE `detail`-Feld
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                      title:
                        type: string
              example:
                error:
                  status: 404
                  title: Not Found

  # ──────────────────────────────────────────────
  # TEST STEPS (Kalibrierung)
  # ──────────────────────────────────────────────
  /api/v2/calibrations/{ctag}/test-steps:
    get:
      tags: [test-step]
      summary: Pruefschritte einer Kalibrierung auflisten
      description: |
        Erfordert Permission `calibration_view`. **Prueft `{ctag}` NICHT auf
        Existenz** (kein `findOrFail`) — bei unbekannter Kalibrierung wird
        stillschweigend eine leere Liste zurueckgegeben statt 404. Sortiert nach
        `row_number`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: CTAG der Kalibrierung
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      responses:
        '200':
          description: Pruefschrittliste (leer, wenn `{ctag}` unbekannt ist)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TestStepResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
                    type: test-step
                    attributes:
                      calibration_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
                      row_number: 1
                      test_step: 1.001
                      description: "DC voltage, 6 V range"
                      test_type: "Manual Test"
                      config: "NUMERIC,UUT INDICATED"
                      range: 6.0
                      test_value: 5.0
                      test_value_u: V
                      resolution: 0.0001
                      tolerance: "0.05% 5U"
                      uncertainty: 0.02
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [test-step]
      summary: Einzelnen Pruefschritt anlegen
      description: |
        Erfordert Permission `calibration_edit`. **Prueft `{ctag}` ebenfalls
        NICHT auf Existenz** — bei unbekannter Kalibrierung wird stillschweigend
        ein verwaister Pruefschritt erzeugt.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestStepInput'
            example:
              row_number: 1
              test_type: "Manual Test"
              description: "600 mV range"
              test_value: 0.5
              test_value_u: V
              range: 0.6
              resolution: 0.0001
              tolerance: "0.1% 1U"
      responses:
        '201':
          description: Pruefschritt erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TestStepResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

    put:
      tags: [test-step]
      summary: Alle Pruefschritte einer Kalibrierung ersetzen (Bulk)
      description: |
        Erfordert Permission `calibration_edit`. Im Gegensatz zu `GET`/`POST`
        auf diesem Pfad WIRD `{ctag}` hier per `findOrFail` geprueft
        (Standard-404). Upsert per `id`; im Payload fehlende Schritte werden
        inkl. ihres `Result`-Datensatzes geloescht. `row_number`/`test_step`
        werden serverseitig aus der Array-Reihenfolge abgeleitet.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [steps]
              properties:
                steps:
                  type: array
                  items:
                    $ref: '#/components/schemas/TestStepInput'
            example:
              steps:
                - id: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
                  test_type: "Manual Test"
                  description: "600 mV range"
                  test_value: 0.5
                - test_type: "Manual Test"
                  description: "6 V range"
                  test_value: 5.0
      responses:
        '200':
          description: Aktueller Pruefschrittsatz (neu abgefragt, kein Echo)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TestStepResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/calibrations/{ctag}/test-steps/reorder:
    post:
      tags: [test-step]
      summary: Pruefschritte einer Kalibrierung neu sortieren
      description: Erfordert Permission `calibration_edit`. `{ctag}` wird per `findOrFail` geprueft.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ordered_ids]
              properties:
                ordered_ids:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    maxLength: 80
            example:
              ordered_ids:
                - "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
                - "c9d0e1f2-5678-4a6b-9234-e5f6a7b8c9d0"
      responses:
        '200':
          description: Neu sortierte Pruefschritte
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TestStepResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/calibrations/{ctag}/load-procedure:
    post:
      tags: [test-step]
      summary: Pruefablauf aus einer Vorlage in die Kalibrierung laden
      description: |
        Erfordert Permission `calibration_edit`. Loescht vorhandene Pruefschritte
        + Ergebnisse und erstellt sie aus der Vorlage neu. Antwortet NUR mit
        `meta` — **kein `data`-Schluessel**. 404 (Standard) mit entweder
        "Procedure not found." oder "ProcedureTemplate not found.". 422
        (deutscher Text), wenn die Vorlage kein `internal`-Kind ist (kein
        ladbarer Ablauf, z.B. Excel/Metcal/Dokument).
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_id]
              properties:
                template_id:
                  type: string
                  maxLength: 36
            example:
              template_id: "d4a1b2c3-5e6f-4789-9abc-def012345678"
      responses:
        '200':
          description: Pruefablauf geladen (kein `data`-Schluessel in der Antwort)
          content:
            application/json:
              schema:
                type: object
                properties:
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
                      steps_created:
                        type: integer
              example:
                meta:
                  message: "Procedure loaded into calibration."
                  steps_created: 12
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Vorlage ist kein ladbarer (`internal`) Pruefablauf
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "Die Vorlage ist eine Excel-Datei und enthält keinen ladbaren Prüfablauf."

  /api/v2/calibrations/{ctag}/wizard:
    get:
      tags: [test-step]
      summary: Gefuehrte Pruefschritt+Ergebnis-Ansicht abrufen
      description: |
        Erfordert Permission `calibration_view`. `{ctag}` wird per `findOrFail`
        geprueft. Jeder Schritt traegt zusaetzlich sein (ggf. `null`) Ergebnis
        unter `attributes.result`. Sortiert nach `row_number`.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      responses:
        '200':
          description: Pruefschritte inkl. Ergebnis
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        type:
                          type: string
                          enum: [test-step]
                        attributes:
                          allOf:
                            - $ref: '#/components/schemas/TestStepAttributes'
                            - type: object
                              properties:
                                result:
                                  type: object
                                  nullable: true
                                  allOf:
                                    - $ref: '#/components/schemas/ResultCaptureAttributes'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
                    type: test-step
                    attributes:
                      calibration_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
                      row_number: 1
                      test_type: "Manual Test"
                      description: "600 mV range"
                      test_value: 0.5
                      test_value_u: V
                      result:
                        row_number: 1
                        fixed_quantity: "0.500"
                        variable_quantity: "0.502"
                        pass_fail: Pass
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/capture-options:
    get:
      tags: [test-step]
      summary: Aktiven Pruefablauf-Status und verfuegbare Aktionen abrufen (CapturePlan)
      description: |
        Erfordert Permission `calibration_view`. `{ctag}` wird per `findOrFail`
        geprueft. `attributes.message` ist bei `status:"no_procedure"`
        deutschsprachiger Originaltext der Anwendung (siehe Beispiel).
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      responses:
        '200':
          description: CapturePlan
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationCaptureResource'
              examples:
                aktive_prozedur:
                  summary: "status: ok — Prozedur und interne Vorlage vorhanden"
                  value:
                    data:
                      id: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
                      type: calibration-capture
                      attributes:
                        status: ok
                        message: null
                        procedure:
                          id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                          name: "Drehmomentschluessel — Vergleich"
                        source: device
                        template:
                          id: "d4a1b2c3-5e6f-4789-9abc-def012345678"
                          name: "DMM Verfahren"
                          file_name: "a1b2c3d4e5f6g7h8i9j0.json"
                          file_type: json
                          version: "1.0"
                          released: release
                          kind: internal
                          kind_label: "Eigene Messwertvorlage"
                        kind: internal
                        kind_label: "Eigene Messwertvorlage"
                        recommended: internal_run
                        options:
                          - action: internal_run
                            label: "Messwertaufnahme starten"
                            description: "Pruefschritte der Vorlage in der internen Messwert-Runtime abarbeiten."
                            available: true
                            reason: null
                            recommended: true
                          - action: download
                            label: "Vorlage herunterladen"
                            description: "a1b2c3d4e5f6g7h8i9j0.json"
                            available: true
                            reason: null
                            recommended: false
                          - action: manual
                            label: "Messwerte von Hand erfassen"
                            description: "Ergebniszeilen direkt im Ergebnisse-Tab der Kalibrierung eintragen."
                            available: true
                            reason: null
                            recommended: false
                        procedure_choices:
                          - id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                            name: "Drehmomentschluessel — Vergleich"
                            source: device
                            is_default: true
                            active: true
                        can_switch: true
                keine_prozedur:
                  summary: "status: no_procedure — kein Geraet-/Typ-Default zugeordnet"
                  value:
                    data:
                      id: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
                      type: calibration-capture
                      attributes:
                        status: no_procedure
                        message: "Für dieses Gerät ist keine Standardprüfprozedur zugeordnet…"
                        procedure: null
                        source: null
                        template: null
                        kind: null
                        kind_label: null
                        recommended: null
                        options: []
                        procedure_choices: []
                        can_switch: false
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/procedure:
    put:
      tags: [test-step]
      summary: Aktive Prozedur der Kalibrierung manuell waehlen
      description: |
        Erfordert Permission `calibration_edit`. `procedure_id` hat keine
        `exists`-Regel im FormRequest, wird aber per `findOrFail` im Controller
        geprueft (Standard-404 "Procedure not found."). Antwort: dieselbe
        CapturePlan-Form wie `GET .../capture-options`, aber **mit 200 (nicht
        204)**, da ein Response-Body vorhanden ist.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [procedure_id]
              properties:
                procedure_id:
                  type: string
                  maxLength: 36
            example:
              procedure_id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
      responses:
        '200':
          description: Prozedur umgestellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationCaptureResource'
              example:
                data:
                  id: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
                  type: calibration-capture
                  attributes:
                    status: ok
                    message: "Prüfprozedur für diese Kalibrierung umgestellt."
                    procedure:
                      id: "c65b8f2a-4e1d-4a9c-8b3f-1a2c3d4e5f6a"
                      name: "Drehmomentschluessel — Vergleich"
                    source: calibration
                    can_switch: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [test-step]
      summary: Manuelle Prozedur-Auswahl der Kalibrierung zuruecksetzen
      description: |
        Erfordert Permission `calibration_edit`. Kein Body. Faellt zurueck auf
        Geraet-/Typ-Default. **Antwortet mit 200 (nicht 204)** — enthaelt wie
        `PUT` einen CapturePlan-Body.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      responses:
        '200':
          description: Auswahl zurueckgesetzt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CalibrationCaptureResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # PROCEDURE RUN
  # ──────────────────────────────────────────────
  /api/v2/calibrations/{ctag}/run:
    get:
      tags: [test-step]
      summary: Aktuellen Procedure-Run einer Kalibrierung abrufen
      description: |
        Erfordert Permission `calibration_view`. `{ctag}` wird per `findOrFail`
        geprueft (Standard-404). Existiert noch KEIN Run, folgt ein ZWEITER,
        abweichend geformter 404 (`application/json` statt
        `application/problem+json`, siehe Beispiel).
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      responses:
        '200':
          description: Aktueller Run mit Fortschritt und Sektionen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureRunResource'
                  meta:
                    type: object
                    properties:
                      progress:
                        type: object
                        properties:
                          required:
                            type: integer
                          captured:
                            type: integer
                          percent:
                            type: number
                      rollup:
                        type: string
                        nullable: true
                        enum: [Pass, Fail, Conditional, Incomplete]
                      sections:
                        type: array
                        items:
                          type: object
                          properties:
                            header:
                              type: object
                              nullable: true
                              additionalProperties: true
                              description: Kopfzeile der Sektion (Header-Pruefschritt) — `null`, wenn die Sektion keine hat.
                            steps:
                              type: array
                              items:
                                type: object
                                additionalProperties: true
                                description: "Pruefschritt mit `step_kind`, `capture_state`, `test_status`, `result` (siehe Beispiel)."
                      instantiated:
                        type: boolean
                        nullable: true
                      api_version:
                        type: string
              example:
                data:
                  id: "c9d0e1f2-5678-4a6b-9234-e5f6a7b8c9d0"
                  type: procedure-run
                  attributes:
                    calibration_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
                    procedure_template_id: "d4a1b2c3-5e6f-4789-9abc-def012345678"
                    template_version: "1.0"
                    template_name: "DMM Verfahren"
                    template_hash: "a1b2c3d4e5f6g7h8i9j0"
                    steps_modified_at: null
                    status: in_progress
                    result_rollup: null
                    started_by: "9f8e7d6c-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
                    started_at: "2026-08-01T09:00:00.000000Z"
                    completed_at: null
                    created_at: "2026-08-01T09:00:00.000000Z"
                    updated_at: "2026-08-01T09:05:00.000000Z"
                meta:
                  progress:
                    required: 2
                    captured: 1
                    percent: 50
                  rollup: null
                  sections:
                    - header:
                        id: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
                        row_number: 1
                        test_step: 1.0
                        description: "DC Voltage"
                        prompt: null
                      steps:
                        - id: "c1d2e3f4-6789-4a5b-9012-d3e4f5a6b7c8"
                          row_number: 2
                          test_step: 1.001
                          step_kind: measure
                          description: "600 mV range"
                          prompt: "Set function to DCV, range 600 mV"
                          config: "NUMERIC,UUT INDICATED"
                          tolerance: "0.1% 1U"
                          test_value: 0.5
                          test_value_p: null
                          test_value_u: V
                          range: 0.6
                          resolution: 0.0001
                          uncertainty: 0.02
                          capture_state: captured
                          test_status: Pass
                          result:
                            row_number: 2
                            fixed_quantity: "0.500"
                            variable_quantity: "0.502"
                            pass_fail: Pass
                        - id: "d2e3f4a5-789a-4b6c-9123-e4f5a6b7c8d9"
                          row_number: 3
                          test_step: 1.002
                          step_kind: measure
                          description: "6 V range"
                          prompt: "Set function to DCV, range 6 V"
                          config: "NUMERIC,UUT INDICATED"
                          tolerance: "0.05% 5U"
                          test_value: 5.0
                          test_value_p: null
                          test_value_u: V
                          range: 6.0
                          resolution: 0.0001
                          uncertainty: 0.02
                          capture_state: pending
                          test_status: null
                          result: null
                  instantiated: null
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Zwei moegliche Ursachen mit unterschiedlicher Form: Kalibrierung nicht
            gefunden (Standard-RFC7807) oder noch kein Run vorhanden (verschachtelte,
            handgebaute Form).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "Calibration not found."
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 404
                  title: Not Found
                  detail: "This calibration has no procedure run yet."

    post:
      tags: [test-step]
      summary: Procedure-Run starten oder fortsetzen
      description: |
        Erfordert Permission `calibration_edit`. Idempotent (setzt einen
        bestehenden offenen Run fort) — **antwortet aber immer mit 201**, auch
        beim Fortsetzen. Hat die Kalibrierung noch keine Pruefschritte, werden
        sie automatisch aus der internen Vorlage der Standardprozedur gesaet.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                mode:
                  type: string
                  enum: [bound]
                  description: Aktuell einziger implementierter Wert.
            example:
              mode: bound
      responses:
        '201':
          description: Run gestartet oder fortgesetzt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureRunResource'
                  meta:
                    type: object
                    properties:
                      progress:
                        type: object
                        additionalProperties: true
                      rollup:
                        type: string
                        nullable: true
                      sections:
                        type: array
                        items:
                          type: object
                          additionalProperties: true
                      instantiated:
                        type: boolean
                        nullable: true
                        description: "`true`, wenn Pruefschritte in diesem Aufruf neu aus der Vorlage gesaet wurden."
                      api_version:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/procedure-runs/{run}:
    get:
      tags: [test-step]
      summary: Procedure-Run per ID abrufen
      description: |
        Erfordert Permission `calibration_view`. Standard-404
        ("ProcedureRun not found."). Gleiche Antwortform wie `GET/POST .../run`.
      parameters:
        - name: run
          in: path
          required: true
          description: UUID des Procedure-Run
          schema:
            type: string
            format: uuid
          example: "c9d0e1f2-5678-4a6b-9234-e5f6a7b8c9d0"
      responses:
        '200':
          description: Run mit Fortschritt und Sektionen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureRunResource'
                  meta:
                    type: object
                    properties:
                      progress:
                        type: object
                        additionalProperties: true
                      rollup:
                        type: string
                        nullable: true
                      sections:
                        type: array
                        items:
                          type: object
                          additionalProperties: true
                      instantiated:
                        type: boolean
                        nullable: true
                      api_version:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/procedure-runs/{run}/complete:
    post:
      tags: [test-step]
      summary: Procedure-Run abschliessen
      description: |
        Erfordert Permission `calibration_edit`. Kein Body. Setzt
        `status="completed"`, `result_rollup`, `completed_at`; schreibt bei
        `rollup != "Incomplete"` zusaetzlich das `cal_result`-Feld der
        Kalibrierung.
        Ist die Kalibrierung an einen Messplatz ausgecheckt (ADR 2026-11-07
        „Autarker Lauf am Messplatz“), antwortet der Endpunkt mit 423
        (`code: checked_out`), bis der Messplatz den Lauf zurueckgibt oder
        er zurueckgeholt wird.
      parameters:
        - name: run
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "c9d0e1f2-5678-4a6b-9234-e5f6a7b8c9d0"
      responses:
        '200':
          description: Run abgeschlossen (neu abgefragt)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureRunResource'
                  meta:
                    type: object
                    additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '423':
          description: An einen Messplatz ausgecheckt (`code` `checked_out`, `checkout`)

  /api/v2/calibrations/{ctag}/run/checkout:
    get:
      tags: [procedure]
      summary: Auscheck-Stand einer Kalibrierung und Pruefung „autark-faehig“
      description: |
        Erfordert Permission `calibration_view`. `data` ist der aktive
        Auscheck-Eintrag oder null. Mit `workstation_id` steht unter
        `meta.check` die Pruefung, ob dieser Messplatz den Lauf ohne calServer
        zu Ende bringen kann: `capable`, `findings` (blockierend, mit `code`,
        `message`, optional `row_number`; etwa `no_run`, `preflight_open`,
        `judgement_branch`, `role_unbound`, `checked_out`) und `notes`
        (Hinweise, etwa `camera_manual`: Kamera wird am Platz zur Handeingabe).
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: workstation_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Auscheck-Stand
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    nullable: true
                    type: object
                    description: "Auscheck-Eintrag: `id`, `workstation_id`, `workstation_name`, `calibration_id`, `run_id`, `batch_id` (Sammellauf oder null), `state` (`checked_out`, `returned`, `cancelled`), `checked_out_at`, `fetched_at`, `returned_at`."
                    additionalProperties: true
                  meta:
                    type: object
                    additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [procedure]
      summary: Lauf an einen Messplatz auschecken (autark)
      description: |
        Erfordert Permission `workstation_run`. Der Lauf geht in den
        Arbeitsvorrat des Messplatzes; der Runner holt ihn mit
        `GET /workstations/me/checkouts` ab und misst dort auch ohne
        Verbindung. Bis zur Rueckgabe antworten Erfassen und Abschliessen
        fuer alle anderen mit 423 (`code: checked_out`). Kann der Platz den
        Lauf nicht autark messen (siehe `meta.check`), 422 mit den Befunden.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workstation_id]
              properties:
                workstation_id:
                  type: string
                  format: uuid
      responses:
        '201':
          description: Ausgecheckt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: "Auscheck-Eintrag: `id`, `workstation_id`, `workstation_name`, `calibration_id`, `run_id`, `batch_id` (Sammellauf oder null), `state` (`checked_out`, `returned`, `cancelled`), `checked_out_at`, `fetched_at`, `returned_at`."
                    additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [procedure]
      summary: Ausgecheckten Lauf zurueckholen
      description: |
        Erfordert Permission `workstation_run`. Der Browser erfasst wieder;
        Werte, die der Messplatz noch sendet, kommen trotzdem an (sein Token
        darf weiter erfassen). 404, wenn nichts ausgecheckt ist.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Zurueckgeholt (`state` `cancelled`)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/workstations/me/checkouts:
    get:
      tags: [procedure]
      summary: Arbeitsvorrat des Messplatzes (Runner)
      description: |
        Runner-Token. Die aktiven Auscheck-Eintraege dieses Messplatzes;
        der Abruf setzt `fetched_at`. Ein Eintrag, der hier fehlt, ist in
        calServer zurueckgeholt; der Runner entfernt dann sein Paket.
      responses:
        '200':
          description: Arbeitsvorrat (`data` Liste, `meta.total`)
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/workstations/me/checkouts/{checkout}/return:
    post:
      tags: [procedure]
      summary: Ausgecheckten Lauf zurueckgeben (Runner)
      description: |
        Runner-Token. Der Runner gibt zurueck, wenn alle Werte aus seinem
        Journal angekommen sind; danach erfasst der Browser wieder.
      parameters:
        - name: checkout
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Zurueckgegeben (`state` `returned`)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/save-as-template:
    post:
      tags: [test-step]
      summary: Aktuelle Pruefschritte der Kalibrierung als Vorlage speichern
      description: |
        **Erfordert `procedure_template_edit`** (NICHT `calibration_edit`!).
        Serialisiert die aktuellen `test_steps` der Kalibrierung in eine neue
        Vorlagen-Datei (`link_table="calibration"`).
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 255
                  nullable: true
                procedure_id:
                  type: string
                  maxLength: 36
                  nullable: true
            example:
              name: "DMM Verfahren (aus Kalibrierung)"
      responses:
        '201':
          description: Vorlage erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProcedureTemplateResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Calibration steps saved as template."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  # ──────────────────────────────────────────────
  # TEST STEPS (Einzel-Operationen und Messwertaufnahme)
  # ──────────────────────────────────────────────
  /api/v2/test-steps/{id}:
    put:
      tags: [test-step]
      summary: Einzelnen Pruefschritt aktualisieren
      description: |
        Partial Update. Erfordert Permission `calibration_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestStepInput'
            example:
              test_value: 0.52
              tolerance: "0.1% 1U"
      responses:
        '200':
          description: Pruefschritt aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TestStepResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [test-step]
      summary: Einzelnen Pruefschritt loeschen
      description: Erfordert Permission `calibration_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/test-steps/{id}/confirm-manual:
    post:
      tags: [test-step]
      summary: Manuellen Pass/Fail-Pruefschritt bestaetigen
      description: |
        Erfordert Permission `calibration_edit`. **Antwortet mit 200 und
        `type:"test-step"`** — anders als der strukturell gleiche
        `kind=confirm`-Zweig von `POST .../capture` (dort 201/`test-step-result`,
        siehe dort). Aktualisiert `cal_result` der Kalibrierung nur, wenn der
        Schritt einer Kalibrierung zugeordnet ist (sonst stiller No-Op, KEIN
        Fehler — anders als `submit-measurement`/`confirm-rating`/`clone`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pass]
              properties:
                pass:
                  type: string
                  enum: [Y, N]
            example:
              pass: Y
      responses:
        '200':
          description: Bestaetigt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      type:
                        type: string
                        enum: [test-step]
                      attributes:
                        allOf:
                          - $ref: '#/components/schemas/TestStepAttributes'
                          - type: object
                            properties:
                              confirmed:
                                type: string
                                enum: [Y, N]
                              test_status:
                                type: string
                                enum: [Pass, Fail]
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Manual test confirmed successfully."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/test-steps/{id}/submit-measurement:
    post:
      tags: [test-step]
      summary: Numerischen Messwert erfassen
      description: |
        Erfordert Permission `calibration_edit`. Persistiert einen `Result`-
        Datensatz. 422 mit verschachtelter, nicht-RFC7807-Form, wenn der
        Pruefschritt keiner Kalibrierung zugeordnet ist (`calibration_id` ist
        `null`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [varq]
              properties:
                varq:
                  type: number
                varq_p:
                  type: string
                  maxLength: 10
                varq_u:
                  type: string
                  maxLength: 20
                remark:
                  type: string
                  maxLength: 500
                comment:
                  type: string
            example:
              varq: 0.502
              varq_u: V
      responses:
        '201':
          description: Messwert erfasst
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ResultCaptureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                meta:
                  message: "Measurement recorded."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Zwei moegliche Ursachen mit unterschiedlicher Form: Validierungsfehler
            (Standard-RFC7807) oder der Pruefschritt ist keiner Kalibrierung
            zugeordnet (verschachtelte, handgebaute Form).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  varq:
                    - "The varq field is required."
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: Unprocessable Entity
                  detail: "The test step is not attached to a calibration."

  /api/v2/test-steps/{id}/preview:
    post:
      tags: [test-step]
      summary: Messwert-Vorschau berechnen (nicht persistiert)
      description: |
        Erfordert Permission `calibration_edit`. Gleicher Body wie
        `submit-measurement`, aber OHNE Persistierung UND OHNE CTAG-Pruefung
        (funktioniert auch fuer verwaiste Schritte). Liefert die ROHEN,
        unuebersetzten Rechen-Spaltennamen der `results`-Tabelle (`row_num`,
        `fixq`, `tol_pos`, `upper_limit`, ...) — NICHT die umbenannte
        `ResultCaptureAttributes`-Form von `submit-measurement`/`confirm-rating`/`capture`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [varq]
              properties:
                varq:
                  type: number
                varq_p:
                  type: string
                  maxLength: 10
                varq_u:
                  type: string
                  maxLength: 20
                remark:
                  type: string
                  maxLength: 500
                comment:
                  type: string
            example:
              varq: 0.502
              varq_u: V
      responses:
        '200':
          description: Vorschau (nicht persistiert)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      type:
                        type: string
                        enum: [test-step-preview]
                      attributes:
                        type: object
                        additionalProperties: true
                        description: Rohe Rechen-Spalten der `results`-Tabelle, unuebersetzt.
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                data:
                  id: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
                  type: test-step-preview
                  attributes:
                    row_num: 1
                    test_step: 1.001
                    fsc: 0.6
                    uut_range: 0.6
                    fixq: 0.5
                    varq: 0.502
                    tol_pos: 0.0005
                    tol_neg: -0.0005
                    upper_limit: 0.5005
                    lower_limit: 0.4995
                    guardband_ll: 0.4996
                    guardband_ul: 0.5004
                    test_status: Pass
                    pass_fail: Pass
                meta:
                  message: "Preview only — not persisted."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/test-steps/{id}/capture:
    post:
      tags: [test-step]
      summary: Pruefschritt-Ergebnis erfassen (vereinheitlichter Endpunkt)
      description: |
        Erfordert Permission `calibration_edit`. `kind` waehlt die Variante:
        `numeric` (`varq` Pflicht), `confirm` (`pass` Pflicht, `Y`/`N`) oder
        `rating` (`rating` Pflicht, `min:1`, **kein Max** — abweichend von
        `/confirm-rating`, dort `max:10`). `kind=confirm` antwortet
        **201/`test-step-result`** (anders als das strukturell gleiche
        `/confirm-manual`, dort 200/`test-step`). Nur `kind=numeric`/`rating`
        liefern 422 (verschachtelte Form), wenn der Schritt keiner Kalibrierung
        zugeordnet ist — `kind=confirm` hat diese Pruefung NICHT.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind:
                  type: string
                  enum: [numeric, confirm, rating, ask, reference]
                  description: "`reference` (ADR 2026-11-07): der Messplatz legt die Ablesung des Normals vor einer Ablesung von Hand oder per Kamera ab (`ref_readings` oder `ref`, `ref_p`, `stability`, `batch_id`)."
                varq:
                  type: number
                  description: Pflicht bei `kind=numeric`
                varq_p:
                  type: string
                  maxLength: 10
                varq_u:
                  type: string
                  maxLength: 20
                remark:
                  type: string
                  maxLength: 500
                pass:
                  type: string
                  enum: [Y, N]
                  description: Pflicht bei `kind=confirm`
                rating:
                  type: integer
                  minimum: 1
                  description: Pflicht bei `kind=rating` (kein Maximum)
                comment:
                  type: string
                journal:
                  type: object
                  description: "Journal des Messplatzes (ADR 2026-11-07 „Messzeitpunkt und Hash-Kette“), bei jeder Art (`numeric`, `reference`, `confirm`, `ask`, `rating`). `hash` ist SHA-256 von `canonical`; `canonical` ist JSON mit `chain_id`, `seq`, `run_id`, `step_id`, `row_number`, `measured_at`, `runner_version`, `profiles`, `prev_hash` und `body` (der Body ohne `journal`). Passt etwas nicht, 422 mit `code` `journal_hash`, `journal_mismatch`, `journal_fork` oder `journal_broken`. Die Antwort trägt `meta.journal` mit `seq`, `status` (`ok`, `gap`) und `measured_at`."
                  required: [chain_id, seq, measured_at, prev_hash, hash, canonical]
                  properties:
                    chain_id:
                      type: string
                      pattern: '^[A-Za-z0-9-]{8,64}$'
                    seq:
                      type: integer
                      minimum: 1
                    measured_at:
                      type: string
                      format: date-time
                    prev_hash:
                      type: string
                      nullable: true
                      pattern: '^[a-f0-9]{64}$'
                    hash:
                      type: string
                      pattern: '^[a-f0-9]{64}$'
                    canonical:
                      type: string
            examples:
              numeric:
                summary: "kind: numeric"
                value:
                  kind: numeric
                  varq: 0.502
                  varq_u: V
              confirm:
                summary: "kind: confirm"
                value:
                  kind: confirm
                  pass: Y
              rating:
                summary: "kind: rating"
                value:
                  kind: rating
                  rating: 8
      responses:
        '201':
          description: Ergebnis erfasst (immer `test-step-result`, unabhaengig von `kind`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ResultCaptureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Validierungsfehler (Standard-RFC7807) ODER — nur bei
            `kind=numeric`/`rating` — Pruefschritt ohne Kalibrierungszuordnung
            (verschachtelte, handgebaute Form).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  kind:
                    - "The kind field is required."
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: Unprocessable Entity
                  detail: "The test step is not attached to a calibration."

  /api/v2/test-steps/{id}/confirm-rating:
    post:
      tags: [test-step]
      summary: Bewertungs-Pruefschritt (Rating) bestaetigen
      description: |
        Erfordert Permission `calibration_edit`. `rating` erlaubt `0`–`10`
        (abweichend von `kind=rating` in `POST .../capture`, dort nur `min:1`,
        kein Max). 422 mit verschachtelter Form, wenn der Schritt keiner
        Kalibrierung zugeordnet ist.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rating]
              properties:
                rating:
                  type: integer
                  minimum: 0
                  maximum: 10
                comment:
                  type: string
                  nullable: true
            example:
              rating: 8
      responses:
        '201':
          description: Bewertung erfasst
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ResultCaptureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
                      test_status:
                        type: string
                        nullable: true
                        enum: [Pass, Fail]
              example:
                meta:
                  message: "Rating recorded."
                  test_status: Pass
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Validierungsfehler (Standard-RFC7807) ODER Pruefschritt ohne
            Kalibrierungszuordnung (verschachtelte, handgebaute Form).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  rating:
                    - "The rating field must not be greater than 10."
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: Unprocessable Entity
                  detail: "The test step is not attached to a calibration."

  /api/v2/test-steps/{id}/clone:
    post:
      tags: [test-step]
      summary: Pruefschritt duplizieren
      description: |
        Erfordert Permission `calibration_edit`. Kein Body. Fuegt die Kopie
        direkt nach dem Original ein und nummeriert alle Schritte der
        Kalibrierung neu durch. 422, wenn der Schritt keiner Kalibrierung
        zugeordnet ist.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b8c9d0e1-4567-4f5a-8123-d4e5f6a7b8c9"
      responses:
        '201':
          description: Kopie erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TestStepResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Pruefschritt ist keiner Kalibrierung zugeordnet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: Unprocessable Entity
                  detail: "The test step is not attached to a calibration."

  # ──────────────────────────────────────────────
  # CUSTOMER
  # ──────────────────────────────────────────────
  /api/v2/customers/export:
    get:
      tags: [customer]
      summary: Kundenliste exportieren
      description: |
        Export als Datei (`csv`/`xlsx`/`pdf`, Content-Disposition: attachment) oder strukturiertes
        JSON (`format=json`). Erfordert Permission `customers_view`.
      parameters:
        - name: format
          in: query
          description: Exportformat
          schema:
            type: string
            enum: [csv, json, xlsx, pdf]
            default: csv
        - name: columns
          in: query
          description: |
            Kommaseparierte Spaltenliste. Standard: name, customer_number, street, zip, city,
            country, phone, email, contact_person
          schema:
            type: string
          example: "name,customer_number,city,email"
        - name: ids
          in: query
          description: Kommaseparierte Liste von Kunden-UUIDs (KTAGs) — schraenkt den Export ein
          schema:
            type: string
          example: "00000000-0000-4000-a000-000000000001,00000000-0000-4000-a000-000000000002"
        - name: with_field_names
          in: query
          description: Spaltenkoepfe als sprechende Feldnamen statt Labels ausgeben
          schema:
            type: boolean
      responses:
        '200':
          description: |
            `format=csv|xlsx|pdf` liefert einen Datei-Download, `format=json` strukturierte Daten.
          content:
            application/json:
              schema:
                type: object
                description: Nur bei format=json
            application/octet-stream:
              schema:
                type: string
                format: binary
                description: Bei format=csv/xlsx/pdf
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/customers:
    get:
      tags: [customer]
      summary: Kundenliste abrufen
      description: |
        Erfordert Permission `customers_view`. Benutzer ohne `customer_global` sehen nur ihnen per
        `map_user` zugeordnete Kunden (ggf. 0 Treffer statt 403).
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[name]"
          in: query
          description: "Filter nach Firmenname (unterstuetzt `like:`)"
          schema:
            type: string
          example: "like:GmbH"
        - name: "filter[customer_number]"
          in: query
          schema:
            type: string
        - name: sort
          in: query
          description: "Sortierfeld, Praefix `-` fuer absteigend. Beispiel: `-created_at`"
          schema:
            type: string
      responses:
        '200':
          description: Kundenliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomerCollection'
              example:
                data:
                  - id: "00000000-0000-4000-a000-000000000001"
                    type: customer
                    attributes:
                      id: "00000000-0000-4000-a000-000000000001"
                      name: "Test GmbH"
                      customer_number: "KD-0001"
                      street: "Teststraße 1"
                      zip: "12345"
                      city: "Berlin"
                      country: "DE"
                      phone: "+49 30 123456"
                      fax: null
                      email: "info@test-gmbh.de"
                      website: null
                      contact_person: "Hans Müller"
                      custom_fields: {}
                      data_source: "cal"
                      external_facility_id: null
                      price_category_id: "pc-standard-uid"
                      price_category_id_label: "Standard"
                      created_at: "2026-01-15T10:00:00.000000Z"
                      updated_at: "2026-01-15T10:00:00.000000Z"
                      deleted_at: null
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0"
                links:
                  self: "/api/v2/customers?page[number]=1&page[size]=25"
                  first: "/api/v2/customers?page[number]=1&page[size]=25"
                  last: "/api/v2/customers?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [customer]
      summary: Kunde anlegen
      description: |
        Erfordert Permission `customers_edit`. Achtung: `name` und alle uebrigen Textfelder sind
        auf **48 Zeichen** begrenzt (nicht 255) — ein 49. Zeichen loest 422 aus.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomerInput'
            example:
              name: "Neue Firma GmbH"
              customer_number: "KD-0002"
              street: "Lieferweg 5"
              zip: "54321"
              city: "München"
              country: "DE"
              phone: "+49 89 987654"
              email: "kontakt@neue-firma.de"
              contact_person: "Erika Musterfrau"
              price_category_id: "pc-standard-uid"
      responses:
        '201':
          description: Kunde erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomerResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/customers/{ktag}:
    get:
      tags: [customer]
      summary: Einzelnen Kunden abrufen
      description: |
        Erfordert Permission `customers_view`. 404 identisch fuer unbekannte und ausserhalb des
        Scopes liegende KTAGs.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: ktag
          in: path
          required: true
          description: UUID des Kunden (KTAG)
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000001"
      responses:
        '200':
          description: Kunde
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomerResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [customer]
      summary: Kunde aktualisieren
      description: |
        Partial Update — nur uebergebene Felder werden geaendert. `name` darf, falls uebergeben,
        nicht leer sein. Erfordert Permission `customers_edit`.
      parameters:
        - name: ktag
          in: path
          required: true
          description: UUID des Kunden (KTAG)
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomerUpdateInput'
            example:
              phone: "+49 30 111222"
              price_category_id: "pc-premium-uid"
      responses:
        '200':
          description: Kunde aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomerResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [customer]
      summary: Kunde loeschen
      description: |
        Soft-Delete (`deleted_at` wird gesetzt) — die Zeile bleibt erhalten, ist danach aber nicht
        mehr ueber Liste/Detail erreichbar. Erfordert Permission `customers_delete`.
      parameters:
        - name: ktag
          in: path
          required: true
          description: UUID des Kunden (KTAG)
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000003"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            Konflikt — Kunde hat zugeordnete Inventarobjekte. Nested Error-Shape (nicht RFC7807).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: Conflict
                  detail: "Customer has related inventories and cannot be deleted."

  /api/v2/customers/bulk-delete:
    post:
      tags: [customer]
      summary: Mehrere Kunden loeschen
      description: |
        IDs ausserhalb des `map_user`-Scopes werden still verworfen (nicht als `skipped` gezaehlt).
        IDs mit zugeordneten Inventarobjekten werden uebersprungen und in `skipped_ids` gemeldet —
        einziger Bulk-Delete-Endpunkt in dieser Domain mit echter Skip-Pruefung. Erfordert `customers_delete`.
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/customers/{ktag}/inventories:
    get:
      tags: [customer]
      summary: Inventar eines Kunden auflisten
      description: |
        Wie `GET /inventories`, aber implizit auf `customer_id={ktag}` beschraenkt.
        Erfordert Permission `customers_view`.
      parameters:
        - name: ktag
          in: path
          required: true
          description: UUID des Kunden (KTAG)
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000001"
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: sort
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Inventarliste des Kunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InventoryCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/customers/{ktag}/users:
    get:
      tags: [customer]
      summary: Dem Kunden zugeordnete Benutzer auflisten
      description: |
        Nicht paginiert — liefert alle Zuordnungen auf einmal (kein `meta.page`/`links`).
        Erfordert Permission `customers_view`.
      parameters:
        - name: ktag
          in: path
          required: true
          description: UUID des Kunden (KTAG)
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000001"
      responses:
        '200':
          description: Zugeordnete Benutzer
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        type:
                          type: string
                          enum: [user]
                        attributes:
                          type: object
                          properties:
                            email:
                              type: string
                              format: email
                            firstname:
                              type: string
                              nullable: true
                            lastname:
                              type: string
                              nullable: true
                            firm:
                              type: string
                              nullable: true
                            from_assigned_group:
                              type: boolean
                            from_sso:
                              type: boolean
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "50000000-0000-4000-a000-000000000001"
                    type: user
                    attributes:
                      email: "user@example.com"
                      firstname: "Max"
                      lastname: "Mustermann"
                      firm: null
                      from_assigned_group: false
                      from_sso: false
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [customer]
      summary: Benutzer einem Kunden zuordnen
      description: |
        Idempotent — erneutes Zuordnen liefert weiterhin 200 statt eines Fehlers. 404 sowohl bei
        unbekanntem `ktag` ("Customer not found.") als auch bei unbekannter `user_id`
        ("User not found."). Erfordert Permission `customers_edit`.
      parameters:
        - name: ktag
          in: path
          required: true
          description: UUID des Kunden (KTAG)
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id:
                  type: string
                  maxLength: 36
                  description: UUID des Benutzers
            example:
              user_id: "50000000-0000-4000-a000-000000000001"
      responses:
        '200':
          description: |
            Zuordnung angelegt (nicht-standardmaessige Antwort ohne `type`/`id`, nur eine Bestaetigung).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                data:
                  message: "User assigned."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/customers/{ktag}/users/{userId}:
    delete:
      tags: [customer]
      summary: Benutzerzuordnung entfernen
      description: |
        Idempotent — kein Fehler, wenn die Zuordnung nicht (mehr) existiert. 404 nur, wenn `ktag`
        selbst unbekannt/ausserhalb des Scopes liegt. Erfordert Permission `customers_edit`.
      parameters:
        - name: ktag
          in: path
          required: true
          description: UUID des Kunden (KTAG)
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000001"
        - name: userId
          in: path
          required: true
          description: UUID des Benutzers
          schema:
            type: string
            format: uuid
          example: "50000000-0000-4000-a000-000000000001"
      responses:
        '204':
          description: Erfolgreich entfernt (auch wenn keine Zuordnung bestand)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/customers/{ktag}/audits:
    get:
      tags: [customer]
      summary: Aenderungsverlauf eines Kunden abrufen
      description: |
        Wie `GET /audits`, aber fest auf `table=customer` und `dataset_id={ktag}` vorgefiltert.
        Erfordert Permission `customers_view`.
      parameters:
        - name: ktag
          in: path
          required: true
          description: UUID des Kunden (KTAG)
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000001"
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[field_name]"
          in: query
          schema:
            type: string
            maxLength: 50
        - name: "filter[type]"
          in: query
          description: "V1-Aktionstyp, z.B. `change_inventory_field`"
          schema:
            type: string
            maxLength: 50
        - name: "filter[from]"
          in: query
          description: Unix-Zeitstempel (Sekunden)
          schema:
            type: integer
        - name: "filter[to]"
          in: query
          description: Unix-Zeitstempel (Sekunden)
          schema:
            type: integer
      responses:
        '200':
          description: Audit-Eintraege des Kunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditCollection'
              example:
                data:
                  - id: "a1b2c3d4-5717-4562-b3fc-2c963f66a099"
                    type: audit
                    attributes:
                      timestamp: 1769990400
                      user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                      type: "change_inventory_field"
                      field_name: "name"
                      title: null
                      data_old: "Test GmbH"
                      data_new: "Neue Test GmbH"
                      table: "customer"
                      dataset_id: "00000000-0000-4000-a000-000000000001"
                      user_label: "Max Mustermann"
                      dataset_label: "Neue Test GmbH"
                      field_label: "Name"
                      data_old_label: null
                      data_new_label: null
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # CONTACT
  # ──────────────────────────────────────────────
  /api/v2/contacts:
    get:
      tags: [contact]
      summary: Kontakte auflisten
      description: |
        Schliesst `status=2` (geloescht) immer aus. Ohne `sort` Standardsortierung nach `name`
        aufsteigend. Erfordert Permission `contact_view`.
      parameters:
        - name: "filter[customer_id]"
          in: query
          description: "Alias fuer `customer_uID` (DB-Spalte)"
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: sort
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Kontaktliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactCollection'
              example:
                data:
                  - id: "10000000-0000-4000-a000-000000000001"
                    type: contact
                    attributes:
                      name: "Test GmbH"
                      firstname: "Hans"
                      contact_person: "Hans Müller"
                      contact: "Hans Müller"
                      street: "Teststraße 1"
                      zip: "12345"
                      location: "Berlin"
                      state: "Berlin"
                      country: "DE"
                      phone: "+49 30 123456"
                      fax: null
                      email: "hans@test-gmbh.de"
                      label: "Hauptkontakt"
                      type: "invoice"
                      status: 1
                      favorite: 1
                      customer_id: "00000000-0000-4000-a000-000000000001"
                      custom_fields: {}
                  - id: "10000000-0000-4000-a000-000000000002"
                    type: contact
                    attributes:
                      name: "Test GmbH Lieferung"
                      firstname: null
                      contact_person: "Maria Schmidt"
                      contact: "Maria Schmidt"
                      street: ""
                      zip: ""
                      location: ""
                      state: ""
                      country: ""
                      phone: null
                      fax: null
                      email: null
                      label: "Lieferadresse"
                      type: "delivery"
                      status: 1
                      favorite: 0
                      customer_id: "00000000-0000-4000-a000-000000000001"
                      custom_fields: {}
                meta:
                  total: 2
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0"
                links:
                  self: "/api/v2/contacts?page[number]=1&page[size]=25"
                  first: "/api/v2/contacts?page[number]=1&page[size]=25"
                  last: "/api/v2/contacts?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [contact]
      summary: Kontakt anlegen
      description: |
        Kein dediziertes FormRequest — Validierung erfolgt inline im Controller. `favorite` wird
        fest auf 0 und `status` fest auf 1 gesetzt (beide per Body nicht steuerbar).
        Erfordert Permission `contact_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactInput'
            example:
              name: "Neue Firma"
              firstname: "Max"
              contact_person: "Max Mustermann"
              street: "Lieferweg 5"
              zip: "12345"
              location: "Berlin"
              state: "Berlin"
              country: "DE"
              email: "max@neue-firma.de"
              label: "Hauptkontakt"
              type: "invoice"
              customer_id: "00000000-0000-4000-a000-000000000001"
      responses:
        '201':
          description: Kontakt erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ContactResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/contacts/favorites:
    get:
      tags: [contact]
      summary: Favorisierte Kontakte auflisten
      description: |
        Wie `GET /contacts`, aber fest auf `favorite=1` gefiltert (weiterhin ohne `status=2`),
        sortiert nach `name`. Erfordert Permission `contact_view`.
      parameters:
        - name: "filter[customer_id]"
          in: query
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Favorisierte Kontakte
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/contacts/{id}:
    get:
      tags: [contact]
      summary: Einzelnen Kontakt abrufen
      description: Erfordert Permission `contact_view`. 404 identisch fuer unbekannt/ausserhalb Kundenscope.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Kontakts
          schema:
            type: string
            format: uuid
          example: "10000000-0000-4000-a000-000000000001"
      responses:
        '200':
          description: Kontakt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ContactResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [contact]
      summary: Kontakt aktualisieren
      description: |
        Partial Update. `favorite` (0/1) ist hier — anders als beim Anlegen — direkt setzbar.
        Erfordert Permission `contact_edit`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Kontakts
          schema:
            type: string
            format: uuid
          example: "10000000-0000-4000-a000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactUpdateInput'
            example:
              phone: "+49 30 999999"
              favorite: 0
      responses:
        '200':
          description: Kontakt aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ContactResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [contact]
      summary: Kontakt loeschen
      description: |
        Hard-Delete — die Zeile wird physisch entfernt (kein Soft-Delete, anders als bei Customer).
        Erfordert Permission `contact_delete`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Kontakts
          schema:
            type: string
            format: uuid
          example: "10000000-0000-4000-a000-000000000002"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/contacts/{id}/duplicate:
    post:
      tags: [contact]
      summary: Kontakt duplizieren
      description: |
        Kein Request-Body. Kopiert saemtliche Attribute inkl. `status`/`favorite` unter neuer UUID.
        Erfordert Permission `contact_edit`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des zu duplizierenden Kontakts
          schema:
            type: string
            format: uuid
          example: "10000000-0000-4000-a000-000000000001"
      responses:
        '201':
          description: Kopie erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ContactResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/contacts/bulk-delete:
    post:
      tags: [contact]
      summary: Mehrere Kontakte loeschen
      description: |
        IDs ausserhalb des Kundenscopes werden von der Vorab-Query still ausgefiltert — `skipped`
        ist bei Contacts daher immer 0. Erfordert Permission `contact_delete`.
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # LOCATION (Leih-/Bewegungsregister)
  # ──────────────────────────────────────────────
  /api/v2/locations:
    get:
      tags: [location]
      summary: Standort-/Verleiheintraege auflisten
      description: |
        Sichtbarkeit folgt der Sichtbarkeit des zugehoerigen INVENTARS (`inventory_global`), nicht
        dem Kundenscope. Erfordert Permission `location_view`.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[inventory_id]"
          in: query
          schema:
            type: string
            format: uuid
        - name: "filter[customer_id]"
          in: query
          schema:
            type: string
            format: uuid
        - name: sort
          in: query
          description: "z.B. `-location_date`"
          schema:
            type: string
      responses:
        '200':
          description: Liste der Standort-/Verleiheintraege
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LocationCollection'
              example:
                data:
                  - id: "20000000-0000-4000-a000-000000000001"
                    type: location
                    attributes:
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      location_1: "Berlin"
                      location_2: "Labor A"
                      location_3: "Schrank 3"
                      location_4: "R204"
                      location_5: "B1"
                      location_date: "2026-05-01"
                      location_time: "08:00:00"
                      rental_end: "2026-05-10"
                      is_active: 1
                      customer_id: "00000000-0000-4000-a000-000000000001"
                      delivery_customer_id: null
                      customer_contact_id: null
                      delivery_contact_id: null
                      status: "Reservation"
                      status_label: "Reservation"
                      data_source: "cal"
                      custom_fields: {}
                      customer_label: "Test GmbH"
                      inventory_label: "DMM-001 - FLUKE - 179"
                      delivery_customer_label: null
                      customer_contact_label: null
                      delivery_contact_label: null
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0"
                links:
                  self: "/api/v2/locations?page[number]=1&page[size]=25"
                  first: "/api/v2/locations?page[number]=1&page[size]=25"
                  last: "/api/v2/locations?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [location]
      summary: Standort-/Verleiheintrag anlegen
      description: |
        `check_availability=true` prueft optional auf Ueberschneidung mit bestehenden Buchungen
        desselben Inventars (anders als bei `POST /reservations`, wo diese Pruefung immer laeuft).
        404, wenn `inventory_id` unbekannt/nicht sichtbar ist (Schreib-IDOR-Schutz). Erfordert `location_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LocationInput'
            example:
              inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
              location_1: "Berlin"
              location_2: "Labor A"
              location_date: "2026-05-01"
              location_time: "08:00:00"
              rental_end: "2026-05-10"
              customer_id: "00000000-0000-4000-a000-000000000001"
              status: "Reservation"
              check_availability: true
      responses:
        '201':
          description: Eintrag erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/LocationResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Zeitraum belegt — nur wenn `check_availability=true` gesendet wurde. Nested Error-Shape
            (kein RFC7807 `ValidationError`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Zeitraum belegt"
                  detail: "Der Zeitraum überschneidet sich mit einer bestehenden Leihe/Reservierung (2026-05-01 – 2026-05-10)."
                  conflict:
                    location_id: "20000000-0000-4000-a000-000000000009"
                    from: "2026-05-01"
                    to: "2026-05-10"
                    status: "Reservation"
        '409':
          description: |
            Fuer dieses Inventar existiert bereits ein Eintrag mit demselben Datum/Uhrzeit
            (eindeutiger DB-Constraint, unabhaengig von `check_availability`). Nested Error-Shape —
            trotz Konflikt-Charakter NICHT `$ref Conflict` (die ProblemDetails-Variante): dieser
            Endpunkt faengt die Constraint-Verletzung selbst ab und rendert sie wie den 422 oben.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: "Eintrag bereits vorhanden"
                  detail: "Für dieses Inventar existiert für 2026-05-01 08:00:00 bereits ein Verleih-/Reservierungseintrag."

  /api/v2/locations/{ltag}:
    get:
      tags: [location]
      summary: Einzelnen Standort-/Verleiheintrag abrufen
      description: Erfordert Permission `location_view`.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
      responses:
        '200':
          description: Standort-/Verleiheintrag
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/LocationResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [location]
      summary: Standort-/Verleiheintrag aktualisieren
      description: |
        `inventory_id` ist nach Anlage nicht mehr aenderbar. `send_receipt=false` unterdrueckt die
        Update-Benachrichtigungsmail. Erfordert Permission `location_edit`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LocationUpdateInput'
            example:
              status: "Ausgeliehen"
              send_receipt: false
      responses:
        '200':
          description: Eintrag aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/LocationResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Zeitraum belegt — nur wenn `check_availability=true` gesendet wurde (nested Error-Shape,
            siehe POST /locations).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Zeitraum belegt"
                  detail: "Der Zeitraum überschneidet sich mit einer bestehenden Leihe/Reservierung (2026-05-01 – 2026-05-10)."
                  conflict:
                    location_id: "20000000-0000-4000-a000-000000000009"
                    from: "2026-05-01"
                    to: "2026-05-10"
                    status: "Reservation"
        '409':
          description: Eindeutiger DB-Constraint verletzt (nested Error-Shape, siehe POST /locations).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: "Eintrag bereits vorhanden"
                  detail: "Für dieses Inventar existiert für 2026-05-01 08:00:00 bereits ein Verleih-/Reservierungseintrag."

    delete:
      tags: [location]
      summary: Standort-/Verleiheintrag loeschen
      description: |
        Hard-Delete. War der geloeschte Eintrag `is_active=1`, wird per Model-Event automatisch der
        naechst-juengste Eintrag desselben Inventars aktiv gesetzt. Erfordert Permission `location_delete`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/locations/bulk-assign:
    post:
      tags: [location]
      summary: Mehrere Inventare auf einmal einlagern/verleihen
      description: |
        Keine Hart-Fehlschlaege bei Einzelkonflikten — Konflikte und nicht sichtbare IDs werden in
        `meta.conflicts`/`meta.forbidden` gemeldet statt die Anfrage abzubrechen. `location_1..3`
        sind hier auf 20 Zeichen begrenzt (nicht 36 wie bei Einzel-Anlage). Kein `rental_end`-Feld.
        Erfordert Permission `location_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LocationBulkAssignInput'
            example:
              inventory_ids:
                - "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                - "ea79b775-8b4b-8e17-98c9-b33e7065ff70"
              location_1: "Berlin"
              location_2: "Labor A"
              location_date: "2026-05-01"
              location_time: "08:00:00"
              customer_id: "00000000-0000-4000-a000-000000000001"
              check_availability: true
      responses:
        '201':
          description: |
            Mindestens eine Location angelegt (`meta.conflicts`/`meta.forbidden` koennen trotzdem
            Eintraege enthalten).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/LocationResource'
                  meta:
                    type: object
                    properties:
                      created:
                        type: integer
                      skipped:
                        type: integer
                      conflicts:
                        type: array
                        items:
                          type: object
                          properties:
                            inventory_id:
                              type: string
                            conflict:
                              type: object
                              additionalProperties: true
                      forbidden:
                        type: array
                        items:
                          type: string
              example:
                data:
                  - id: "20000000-0000-4000-a000-000000000010"
                    type: location
                    attributes:
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      location_1: "Berlin"
                      location_2: "Labor A"
                      location_date: "2026-05-01"
                      location_time: "08:00:00"
                      is_active: 1
                      customer_id: "00000000-0000-4000-a000-000000000001"
                meta:
                  created: 1
                  skipped: 1
                  conflicts:
                    - inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff70"
                      conflict:
                        location_id: "20000000-0000-4000-a000-000000000009"
                        from: "2026-04-28"
                        to: "2026-05-03"
                  forbidden: []
        '422':
          description: |
            Entweder normale Validierung (z.B. `inventory_ids` fehlt, ProblemDetails) oder — bei
            gueltiger Anfrage aber 0 angelegten Locations, weil alle IDs im Konflikt/nicht sichtbar
            waren — dieselbe Body-Struktur wie 201 mit `meta.created: 0`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ProblemDetails'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/LocationResource'
                      meta:
                        type: object
                        properties:
                          created:
                            type: integer
                            example: 0
                          skipped:
                            type: integer
                          conflicts:
                            type: array
                            items:
                              type: object
                              additionalProperties: true
                          forbidden:
                            type: array
                            items:
                              type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/locations/bulk-delete:
    post:
      tags: [location]
      summary: Mehrere Standort-/Verleiheintraege loeschen
      description: |
        Loescht Zeile fuer Zeile (kein Batch-Query) — die `is_active`-Promotion (siehe
        `DELETE /locations/{ltag}`) feuert dadurch pro geloeschter Zeile einzeln. IDs ausserhalb des
        Scopes werden still ausgefiltert, `skipped` ist daher immer 0. Erfordert `location_delete`.
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # LOCATION REPORTS
  # ──────────────────────────────────────────────
  /api/v2/locations/{ltag}/reports:
    get:
      tags: [location]
      summary: Verfuegbare Reports eines Standorteintrags auflisten
      description: |
        Gefiltert nach Rollen-/Gruppensichtbarkeit (`grid_name=location`).
        Erfordert Permission `location_view`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
      responses:
        '200':
          description: Verfuegbare Reports
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportSettingResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "40000000-0000-4000-a000-000000000001"
                    type: report-setting
                    attributes:
                      button_name: "Leihschein"
                      menu: null
                      format: pdf
                      folder_name: "location"
                      report_name: "Location.jrxml"
                      is_label: false
                      is_print: true
                      email_attachment: true
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/locations/{ltag}/reports/{reportId}/parameters:
    get:
      tags: [location]
      summary: Prompt-Parameter eines Reports abrufen
      description: |
        Nur Parameter mit `isForPrompting`, die nicht automatisch befuellt werden (`LocationId`/
        `InventoryId` sind bereits vorbelegt und fehlen daher). 404 auch, wenn der Report fuer die
        Rolle/Gruppe des Aufrufers nicht sichtbar ist (bewusst 404 statt 403, generische Meldung).
        Erfordert Permission `location_view`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
        - name: reportId
          in: path
          required: true
          description: UUID der Report-Einstellung
          schema:
            type: string
            format: uuid
          example: "40000000-0000-4000-a000-000000000001"
      responses:
        '200':
          description: Prompt-Parameter
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportParameterResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "Start_Date"
                    type: report-parameter
                    attributes:
                      name: "Start_Date"
                      type: "Date"
                      default: null
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/locations/{ltag}/reports/{reportId}/preview:
    get:
      tags: [location]
      summary: Report-Vorschau als PDF
      description: |
        Rendert immer als PDF, unabhaengig vom spaeteren Zielformat (Content-Disposition: inline).
        Erfordert Permission `location_view`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
        - name: reportId
          in: path
          required: true
          description: UUID der Report-Einstellung
          schema:
            type: string
            format: uuid
          example: "40000000-0000-4000-a000-000000000001"
        - name: "parameters[Name]"
          in: query
          description: "Dynamischer Report-Parameter, `Name` ist ein Platzhalter (siehe .../parameters)"
          schema:
            type: string
          example: "parameters[Start_Date]=2026-05-01"
      responses:
        '200':
          description: PDF-Binaerdatei
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          description: Report-Engine-Fehler (nested Error-Shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: "Report Generation Failed"
                  detail: "Jasper engine returned a non-zero exit code."

  /api/v2/locations/{ltag}/reports/generate:
    post:
      tags: [location]
      summary: Report generieren und herunterladen
      description: Erfordert Permission `location_edit`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                  description: Wird entgegengenommen, von diesem Controller aber ignoriert
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "40000000-0000-4000-a000-000000000001"
              format: pdf
              parameters:
                Start_Date: "2026-05-01"
      responses:
        '200':
          description: "Binaerdatei-Download (Content-Disposition: attachment), Format laut Request-Parameter `format`"
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          description: Report-Engine-Fehler (nested Error-Shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: "Report Generation Failed"
                  detail: "Jasper engine returned a non-zero exit code."

  /api/v2/locations/{ltag}/reports/send:
    post:
      tags: [location]
      summary: Report per E-Mail versenden
      description: |
        Ein fehlgeschlagener Mailversand (SMTP lehnt ab o.ae.) liefert weiterhin 201 mit
        `attributes.status: "failed"` in der MailerMessageResource — nur ein kompletter
        Generierungs- oder Mailer-Konfigurationsfehler liefert 502/503. Erfordert `location_edit`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id, to_address, subject, body]
              properties:
                report_id:
                  type: string
                  format: uuid
                to_address:
                  type: string
                  format: email
                  maxLength: 255
                subject:
                  type: string
                  maxLength: 255
                body:
                  type: string
                cc_address:
                  type: string
                  maxLength: 255
                bcc_address:
                  type: string
                  maxLength: 255
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "40000000-0000-4000-a000-000000000001"
              to_address: "kunde@test-gmbh.de"
              subject: "Ihr Leihschein"
              body: "Anbei der Leihschein zu Ihrer Reservierung."
              format: pdf
      responses:
        '201':
          description: E-Mail versendet oder Versand fehlgeschlagen (siehe `attributes.status`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/MailerMessageResource'
              example:
                data:
                  id: "60000000-0000-4000-a000-000000000001"
                  type: mailer-message
                  attributes:
                    status: sent
                    error_message: null
                    to_address: "kunde@test-gmbh.de"
                    subject: "Ihr Leihschein"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: Report-Engine-Fehler (nested Error-Shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: "Report Generation Failed"
                  detail: "Jasper engine returned a non-zero exit code."
        '503':
          description: Mailer nicht konfiguriert (nested Error-Shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 503
                  title: "Mailer Configuration Missing"
                  detail: "No mail transport is configured for this tenant."

  # ──────────────────────────────────────────────
  # RESERVATION / LOAN CALENDAR
  # ──────────────────────────────────────────────
  /api/v2/reservations/calendar:
    get:
      tags: [reservation]
      summary: Kalenderansicht der Reservierungen/Verleihe abrufen
      description: |
        Zeitfenster max. 400 Tage (`to` - `from`). `category_id` ist teilbaum-inklusive
        (Unterkategorien werden mitgezaehlt). Erfordert Permission `reservation_view`.
      parameters:
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date
          example: "2026-05-01"
        - name: to
          in: query
          required: true
          description: "`>= from`, Fenster max. 400 Tage"
          schema:
            type: string
            format: date
          example: "2026-05-31"
        - name: "inventory_id[]"
          in: query
          schema:
            type: array
            items:
              type: string
              maxLength: 80
        - name: customer_id
          in: query
          schema:
            type: string
            maxLength: 80
        - name: "status[]"
          in: query
          schema:
            type: array
            items:
              type: string
              maxLength: 80
        - name: "category_id[]"
          in: query
          description: Teilbaum-inklusive
          schema:
            type: array
            items:
              type: string
      responses:
        '200':
          description: Kalender-Ereignisse
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReservationEventResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      from:
                        type: string
                        format: date
                      to:
                        type: string
                        format: date
              example:
                data:
                  - id: "20000000-0000-4000-a000-000000000001"
                    type: reservation-event
                    attributes:
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      inventory_label: "DMM-001 - FLUKE - 179"
                      title: "[Test GmbH] SN-CAL-1 - Fluke - Multimeter"
                      start: "2026-05-01"
                      end: "2026-05-10"
                      has_end: true
                      all_day: true
                      location_time: "08:00:00"
                      status: "Reservation"
                      status_label: "Reservation"
                      color: "#BB54FF"
                      text_color: "#FFFFFF"
                      is_active: 1
                      customer_id: "00000000-0000-4000-a000-000000000001"
                      customer_label: "Test GmbH"
                      set_id: null
                      editable: true
                meta:
                  total: 1
                  from: "2026-05-01"
                  to: "2026-05-31"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/rental-units/categories:
    get:
      tags: [reservation]
      summary: Verleih-Kategorien mit Belegungszahlen abrufen
      description: |
        Nur ausleihbare Kategorien mit `total>0`. `free` bleibt `null`, solange nicht beide `from`
        und `to` gesetzt sind. Erfordert Permission `reservation_view`.
      parameters:
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: only_sets
          in: query
          schema:
            type: boolean
        - name: include_blocked
          in: query
          schema:
            type: boolean
      responses:
        '200':
          description: Kategorien mit Belegungszahlen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/RentalUnitCategoryResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "60000000-0000-4000-a000-000000000001"
                    type: rental-unit-category
                    attributes:
                      parent_id: null
                      name: "Messtechnik (Verleih)"
                      color: "#2196F3"
                      text_color: "#FFFFFF"
                      total: 5
                      free: 3
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/rental-units:
    get:
      tags: [reservation]
      summary: Verleihmittel (Einzelgeraete und Sets) durchsuchen
      description: |
        `page[size]` ist hier auf **50** begrenzt (abweichend vom sonst ueblichen Limit 100) —
        `page[size]=500` liefert 422. Erfordert Permission `reservation_view`.
      parameters:
        - name: q
          in: query
          schema:
            type: string
            maxLength: 120
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          description: "`>= from`, Fenster max. 400 Tage"
          schema:
            type: string
            format: date
        - name: "category_id[]"
          in: query
          schema:
            type: array
            items:
              type: string
              maxLength: 80
        - name: customer_id
          in: query
          schema:
            type: string
            maxLength: 80
        - name: only_available
          in: query
          schema:
            type: boolean
        - name: include_parts
          in: query
          description: Standard false
          schema:
            type: boolean
            default: false
        - name: only_sets
          in: query
          schema:
            type: boolean
        - name: include_blocked
          in: query
          schema:
            type: boolean
        - name: sort
          in: query
          schema:
            type: string
            enum: [relevance, asset_number, description]
        - name: "page[size]"
          in: query
          description: "Datensaetze pro Seite (Standard 25, max **50**)"
          schema:
            type: integer
            default: 25
            maximum: 50
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Verleihmittel
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/RentalUnitResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
                      from:
                        type: string
                        format: date
                        nullable: true
                      to:
                        type: string
                        format: date
                        nullable: true
              example:
                data:
                  - id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    type: rental-unit
                    attributes:
                      kind: single
                      asset_number: "DMM-001"
                      serial_number: "SN-1234567890"
                      description: "Digital Multimeter"
                      manufacturer: "FLUKE"
                      model: "179"
                      customer_id: null
                      category_labels: ["Messtechnik (Verleih)"]
                      part_count: 0
                      availability:
                        state: free
                        blocked_from: null
                        blocked_to: null
                        blocked_parts: 0
                        next_free_from: null
                      blocked_reason: null
                      bookable: true
                      parts: []
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  from: "2026-05-01"
                  to: "2026-05-31"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/reservations:
    post:
      tags: [reservation]
      summary: Reservierung/Verleih anlegen (Kalender-Buchung)
      description: |
        Wie `POST /locations`, aber die Verfuegbarkeitspruefung laeuft hier **immer** — ein
        mitgesendetes `check_availability` wird ignoriert. Erfordert Permission `reservation_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/LocationInput'
                - type: object
                  properties:
                    send_receipt:
                      type: boolean
                      default: true
            example:
              inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
              location_date: "2026-05-01"
              rental_end: "2026-05-10"
              customer_id: "00000000-0000-4000-a000-000000000001"
              send_receipt: true
      responses:
        '201':
          description: Reservierung angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/LocationResource'
                  meta:
                    type: object
                    properties:
                      receipt:
                        type: string
                        enum: [sent, skipped, failed]
              example:
                data:
                  id: "20000000-0000-4000-a000-000000000011"
                  type: location
                  attributes:
                    inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    location_date: "2026-05-01"
                    location_time: "00:00:00"
                    rental_end: "2026-05-10"
                    is_active: 1
                    customer_id: "00000000-0000-4000-a000-000000000001"
                meta:
                  receipt: sent
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Zeitraum belegt — wird bei diesem Endpunkt immer geprueft (nested Error-Shape)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Zeitraum belegt"
                  detail: "Der Zeitraum überschneidet sich mit einer bestehenden Leihe/Reservierung (2026-05-01 – 2026-05-10)."
        '409':
          description: Eindeutiger DB-Constraint verletzt (nested Error-Shape, siehe POST /locations).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: "Eintrag bereits vorhanden"
                  detail: "Für dieses Inventar existiert für 2026-05-01 00:00:00 bereits ein Verleih-/Reservierungseintrag."

  /api/v2/locations/{ltag}/schedule:
    patch:
      tags: [reservation]
      summary: Zeitraum eines Verleihs verschieben
      description: |
        Aendert nur `location_date`/`rental_end` — alle anderen Felder bleiben unberuehrt. Die
        eigene aktuelle Buchung wird von der Ueberschneidungspruefung ausgenommen.
        Erfordert Permission `reservation_edit`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID des Standort-/Verleiheintrags (LTAG)
          schema:
            type: string
            format: uuid
          example: "20000000-0000-4000-a000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [location_date]
              properties:
                location_date:
                  type: string
                  format: date
                rental_end:
                  type: string
                  format: date
                  description: "`>= location_date`"
                send_receipt:
                  type: boolean
                  default: true
            example:
              location_date: "2026-06-01"
              rental_end: "2026-06-10"
              send_receipt: false
      responses:
        '200':
          description: Zeitraum verschoben
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/LocationResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Zeitraum belegt (nested Error-Shape, siehe POST /locations)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Zeitraum belegt"
                  detail: "Der Zeitraum überschneidet sich mit einer bestehenden Leihe/Reservierung (2026-06-01 – 2026-06-10)."

  /api/v2/reservations/sets:
    post:
      tags: [reservation]
      summary: Set-Reservierung anlegen (atomare Mehrteile-Buchung)
      description: |
        `inventory_id` muss das Wurzelgeraet des Sets sein — die Teile werden serverseitig aus
        der Hierarchie aufgeloest, eine Teileliste kann nicht mitgeschickt werden. Bucht atomar: ein
        Konflikt bei einem Teil macht die gesamte Buchung rueckgaengig. Erfordert `reservation_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/LocationInput'
                - type: object
                  properties:
                    send_receipt:
                      type: boolean
                      default: true
                    note:
                      type: string
                      maxLength: 255
            example:
              inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff70"
              location_date: "2026-05-01"
              rental_end: "2026-05-10"
              send_receipt: false
              note: "Messkoffer für Außeneinsatz"
      responses:
        '201':
          description: Set-Reservierung angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ReservationSetResource'
                  meta:
                    type: object
                    properties:
                      receipt:
                        type: string
                        enum: [sent, skipped, failed]
              example:
                data:
                  id: "30000000-0000-4000-a000-000000000001"
                  type: reservation-set
                  attributes:
                    root_inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff70"
                    created_by: "45f09720-082f-5c74-9cfa-a605e2863904"
                    note: "Messkoffer für Außeneinsatz"
                    created_at: "2026-04-20T09:00:00.000000Z"
                    updated_at: "2026-04-20T09:00:00.000000Z"
                    location_ids:
                      - "20000000-0000-4000-a000-000000000020"
                      - "20000000-0000-4000-a000-000000000021"
                      - "20000000-0000-4000-a000-000000000022"
                    part_count: 2
                meta:
                  receipt: skipped
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: "Set enthaelt mindestens ein fuer den Aufrufer nicht sichtbares Geraet (nested Error-Shape)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 403
                  title: "Set enthält nicht sichtbare Geräte"
                  detail: "Dieses Set enthält ein Gerät, das Sie nicht sehen dürfen. Ein Set wird immer vollständig gebucht, storniert und verschoben — deshalb ist der Vorgang nicht möglich."
                  hidden_devices: 1
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Mindestens ein Teil ist im gewaehlten Zeitraum bereits verliehen (nested Error-Shape)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Set im Zeitraum nicht verfügbar"
                  detail: "Im gewählten Zeitraum bereits verliehen: PART-2."
                  conflicts:
                    - inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff71"
                      asset_number: "PART-2"
                      from: "2026-05-03"
                      to: "2026-05-07"

  /api/v2/reservations/sets/{id}/schedule:
    patch:
      tags: [reservation]
      summary: Zeitraum eines Sets verschieben
      description: |
        Verschiebt alle Location-Zeilen des Sets atomar. Anders als bei der Einzel-Variante gibt es
        hier **kein** `send_receipt`. Erfordert Permission `reservation_edit`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Reservierungs-Sets
          schema:
            type: string
            format: uuid
          example: "30000000-0000-4000-a000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [location_date]
              properties:
                location_date:
                  type: string
                  format: date
                rental_end:
                  type: string
                  format: date
                  description: "`>= location_date`"
            example:
              location_date: "2026-06-01"
              rental_end: "2026-06-10"
      responses:
        '200':
          description: Zeitraum des Sets verschoben
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ReservationSetResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: "Set enthaelt mindestens ein fuer den Aufrufer nicht sichtbares Geraet (nested Error-Shape, siehe POST /reservations/sets)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 403
                  title: "Set enthält nicht sichtbare Geräte"
                  detail: "Dieses Set enthält ein Gerät, das Sie nicht sehen dürfen. Ein Set wird immer vollständig gebucht, storniert und verschoben — deshalb ist der Vorgang nicht möglich."
                  hidden_devices: 1
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Mindestens ein Teil ist im neuen Zeitraum bereits verliehen (nested Error-Shape, siehe POST /reservations/sets)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Set im Zeitraum nicht verfügbar"
                  detail: "Im gewählten Zeitraum bereits verliehen: PART-2."
                  conflicts:
                    - inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff71"
                      asset_number: "PART-2"
                      from: "2026-06-03"
                      to: "2026-06-07"

  /api/v2/reservations/sets/{id}:
    delete:
      tags: [reservation]
      summary: Set-Reservierung stornieren
      description: |
        Untypisch: liefert **200 mit Body** statt 204. Loescht alle Location-Zeilen des Sets, danach
        die Set-Zeile selbst. Die Sichtbarkeitspruefung greift auch beim Stornieren.
        Erfordert Permission `reservation_delete`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Reservierungs-Sets
          schema:
            type: string
            format: uuid
          example: "30000000-0000-4000-a000-000000000001"
      responses:
        '200':
          description: "Storniert — Antwort enthaelt bewusst KEINEN `data`-Schluessel, nur `meta`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  meta:
                    type: object
                    properties:
                      deleted_locations:
                        type: integer
              example:
                meta:
                  deleted_locations: 3
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            Set enthaelt mindestens ein fuer den Aufrufer nicht sichtbares Geraet —
            Sichtbarkeitspruefung greift auch beim Stornieren (nested Error-Shape).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 403
                  title: "Set enthält nicht sichtbare Geräte"
                  detail: "Dieses Set enthält ein Gerät, das Sie nicht sehen dürfen. Ein Set wird immer vollständig gebucht, storniert und verschoben — deshalb ist der Vorgang nicht möglich."
                  hidden_devices: 1
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # TICKETS
  # ──────────────────────────────────────────────
  /api/v2/tickets:
    get:
      tags: [ticket]
      summary: Tickets auflisten
      description: |
        Paginierte Ticketliste. Erfordert Permission `support_view`. Ein
        unbekannter `sort`-Wert faellt still auf `created_at` absteigend zurueck
        — auch wenn eine andere Richtung angefragt wurde.
      parameters:
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: project_id
          in: query
          description: Exakter Filter auf `project_id`
          schema:
            type: integer
          example: 4
        - name: ticket_status
          in: query
          description: Exakter Filter auf die Status-UUID
          schema:
            type: string
            format: uuid
        - name: priority
          in: query
          schema:
            type: integer
          example: 7
        - name: ticket_type
          in: query
          schema:
            type: integer
          example: 1
        - name: ticket_category
          in: query
          schema:
            type: integer
          example: 2
        - name: risk_1
          in: query
          description: |
            Stufe der ersten Bewertungsebene (Auswirkung), Id aus
            `GET /risks/distribution`. Zusammen mit `risk_2` ist das genau eine
            Zelle der Risiko-Matrix.
          schema:
            type: integer
          example: 3
        - name: risk_2
          in: query
          description: Stufe der zweiten Bewertungsebene (Wahrscheinlichkeit)
          schema:
            type: integer
        - name: risk_3
          in: query
          description: Stufe der dritten Bewertungsebene (Ereignis)
          schema:
            type: integer
        - name: risk
          in: query
          description: |
            uID einer Zeile der Risiko-Matrix (`GET /risks`): liefert die
            Tickets, deren berechnetes Risiko in dieses Band faellt. Gerechnet
            wird mit der konfigurierten Formel und derselben Regel wie in der
            Anzeige — eine unvollstaendige Bewertung faellt in kein Band. Eine
            unbekannte uID liefert eine leere Liste, nicht alle Tickets.
          schema:
            type: string
            format: uuid
        - name: my_tickets
          in: query
          description: "Beliebiger Truthy-Wert: Ticket ist Melder, Zugewiesener ODER CC-Empfaenger des Aufrufers"
          schema:
            type: boolean
        - name: scope
          in: query
          description: |
            `mine` wird immer beruecksichtigt (wie `my_tickets`). `all` wird
            akzeptiert, ist fuer Tickets aktuell aber ein No-Op.
          schema:
            type: string
            enum: [mine, all]
        - name: assigned_to_me
          in: query
          description: Beliebiger Truthy-Wert — nur dem Aufrufer zugewiesene Tickets
          schema:
            type: boolean
        - name: search
          in: query
          description: Teilstring-Suche (LIKE) auf `title` ODER `keywords`
          schema:
            type: string
          example: "display"
        - name: sort
          in: query
          description: |
            `id`, `title`, `ticket_status`, `priority`, `ticket_type`,
            `created_at` (Standard, `-created_at`), `updated_at`. Praefix `-`
            fuer absteigend.
          schema:
            type: string
          example: "-created_at"
      responses:
        '200':
          description: Ticketliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketCollection'
              example:
                data:
                  - id: 42
                    type: ticket
                    attributes:
                      title: "Display defekt"
                      alias: "display-defekt"
                      description: "Bildschirm zeigt keine Werte mehr an."
                      description_markdown: null
                      ticket_version: 3
                      fixed_in: null
                      reported_by_id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                      assigned_to_id: ""
                      assigned_to_group: 5
                      ticket_type: 1
                      priority: 7
                      ticket_status: "c6f35048-6556-5dba-602c-10eed9cb2473"
                      ticket_category: 2
                      project_id: 4
                      milestone: 6
                      risk: 12
                      risk3: 2
                      keywords: "hardware,urgent"
                      last_comment_id: 0
                      created_at: "2026-08-03 09:12:00"
                      updated_at: "2026-08-03 09:40:11"
                      risk_color: "FF0000"
                    relationships:
                      version: []
                      milestone: []
                      risk3: []
                      reporter: []
                      assignee: []
                      project: []
                      type: []
                      priority: []
                      category: []
                      status: []
                      group: []
                      cc_recipients: []
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                links:
                  self: "/api/v2/tickets?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [ticket]
      summary: Ticket anlegen
      description: |
        Erfordert Permission `support_edit`. `reported_by_id` wird serverseitig
        aus dem authentifizierten Benutzer gesetzt, `alias` aus `title`
        generiert. `priority` kann durch die Risikomatrix neu berechnet werden
        (siehe `TicketInput`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketInput'
            example:
              title: "Display defekt"
              description: "Bildschirm zeigt keine Werte mehr an."
              ticket_type: 1
              priority: 2
              cc_recipients: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
      responses:
        '201':
          description: Ticket erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/tickets/{id}:
    get:
      tags: [ticket]
      summary: Einzelnes Ticket abrufen
      description: Erfordert Permission `support_view`.
      parameters:
        - name: id
          in: path
          required: true
          description: Ticket-ID
          schema:
            type: integer
          example: 42
      responses:
        '200':
          description: Ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
              example:
                data:
                  id: 42
                  type: ticket
                  attributes:
                    title: "Display defekt"
                    alias: "display-defekt"
                    description: "Bildschirm zeigt keine Werte mehr an."
                    description_markdown: null
                    ticket_version: 3
                    fixed_in: null
                    reported_by_id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                    assigned_to_id: "c3d4e5f6-a7b8-4901-9234-567890abcdef"
                    assigned_to_group: 5
                    ticket_type: 1
                    priority: 7
                    ticket_status: "c6f35048-6556-5dba-602c-10eed9cb2473"
                    ticket_category: 2
                    project_id: 4
                    milestone: 6
                    risk: 12
                    risk3: 2
                    keywords: "hardware,urgent"
                    last_comment_id: 3
                    created_at: "2026-08-03 09:12:00"
                    updated_at: "2026-08-03 09:40:11"
                    risk_color: "FF0000"
                  relationships:
                    version:
                      id: 3
                      title: "v2.0"
                      risk: 4
                    milestone:
                      id: 6
                      title: "Sprint 12"
                      risk: 3
                    risk3:
                      id: 2
                      title: "Mittel"
                      risk: 2
                    reporter:
                      id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                      type: user
                      name: "Max Mustermann"
                    assignee:
                      id: "c3d4e5f6-a7b8-4901-9234-567890abcdef"
                      type: user
                      name: "Erika Musterfrau"
                    project:
                      id: 4
                      type: project
                      title: "Kalibrierlabor Migration"
                    type:
                      id: 1
                      title: "Bug"
                    priority:
                      id: 7
                      title: "High"
                      background_color: "#ff0000"
                      color: "#ffffff"
                    category:
                      id: 2
                      title: "Backend"
                    status:
                      id: "c6f35048-6556-5dba-602c-10eed9cb2473"
                      title: "Neu"
                      background_color: "#CE3535"
                      color: "#FFFFFF"
                    group:
                      id: 5
                      type: group
                      name: "Tier-1 Support"
                    cc_recipients:
                      data:
                        - id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                          type: user
                          name: "Peter Schmidt"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [ticket]
      summary: Ticket aktualisieren
      description: |
        Partial Update. Erfordert Permission `support_edit`. `cc_recipients` ist
        write-only und ersetzt die CC-Liste vollstaendig (`sync()`). `priority`
        kann durch die Risikomatrix neu berechnet werden (siehe `TicketInput`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketUpdateInput'
            example:
              priority: 9
              ticket_status: "26ba494f-55c7-57fd-e66b-c967b54c5e5e"
      responses:
        '200':
          description: Ticket aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [ticket]
      summary: Ticket loeschen
      description: |
        Loescht zugehoerige Kommentare und deren Aenderungen zuerst. Erfordert
        Permission `support_delete`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/tickets/{id}/groups:
    get:
      tags: [ticket]
      summary: Zugewiesene Gruppe eines Tickets abrufen
      description: |
        Kein eigener Resource-Typ — Ad-hoc-JSON mit 0 oder 1 Zeile (die aktuell
        zugewiesene Gruppe). `meta` enthaelt nur `total`, kein `page`/`links`.
        Erfordert Permission `support_view`.
      parameters:
        - name: id
          in: path
          required: true
          description: Ticket-ID
          schema:
            type: integer
          example: 42
      responses:
        '200':
          description: Gruppenzuweisung (0 oder 1 Eintrag)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    maxItems: 1
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        type:
                          type: string
                          enum: [group]
                        attributes:
                          type: object
                          properties:
                            name:
                              type: string
                            support_ticket:
                              type: integer
                              enum: [0, 1]
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: 5
                    type: group
                    attributes:
                      name: "Tier-1 Support"
                      support_ticket: 1
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/tickets/{id}/assignable-groups:
    get:
      tags: [ticket]
      summary: Zuweisbare Gruppen fuer ein Ticket auflisten
      description: |
        Gruppen mit `support_ticket=1`, OHNE die aktuell zugewiesene Gruppe,
        sortiert nach `name`. Gleiches Ad-hoc-Format wie
        `GET /tickets/{id}/groups` (nur `meta.total`, kein `page`/`links`).
        Erfordert Permission `support_view`.
      parameters:
        - name: id
          in: path
          required: true
          description: Ticket-ID
          schema:
            type: integer
          example: 42
      responses:
        '200':
          description: Zuweisbare Gruppen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        type:
                          type: string
                          enum: [group]
                        attributes:
                          type: object
                          properties:
                            name:
                              type: string
                            support_ticket:
                              type: integer
                              enum: [0, 1]
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: 6
                    type: group
                    attributes:
                      name: "Tier-2 Support"
                      support_ticket: 1
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/tickets/{id}/assign-to-me:
    post:
      tags: [ticket]
      summary: Ticket dem Aufrufer zuweisen
      description: |
        Kein Request-Body. Setzt `assigned_to_id` auf die UUID des
        authentifizierten Benutzers. Erfordert Permission `support_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      responses:
        '200':
          description: Ticket zugewiesen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/tickets/{id}/unassign:
    post:
      tags: [ticket]
      summary: Benutzerzuweisung eines Tickets aufheben
      description: |
        Kein Request-Body. Setzt `assigned_to_id` auf einen leeren String `""`
        (NICHT `null`). Erfordert Permission `support_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      responses:
        '200':
          description: Zuweisung aufgehoben
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/tickets/{id}/change-status:
    post:
      tags: [ticket]
      summary: Ticketstatus aendern
      description: |
        Inline validiert (keine eigene FormRequest-Klasse). Im Unterschied zu
        `PUT /tickets/{id}` wird `ticket_status` hier per `exists`-Regel gegen
        die Status-Tabelle (type=`support_tickets`) geprueft — bei ungueltiger
        UUID liefert 422 die Standardmeldung "The selected ticket status is
        invalid.". Erfordert Permission `support_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ticket_status]
              properties:
                ticket_status:
                  type: string
                  description: UUID eines Status vom Typ `support_tickets`
            example:
              ticket_status: "26ba494f-55c7-57fd-e66b-c967b54c5e5e"
      responses:
        '200':
          description: Status geaendert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/tickets/{id}/assign-group:
    post:
      tags: [ticket]
      summary: Ticket einer Gruppe zuweisen
      description: Inline validiert. Erfordert Permission `support_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [group_id]
              properties:
                group_id:
                  type: integer
            example:
              group_id: 5
      responses:
        '200':
          description: Gruppe zugewiesen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/tickets/{id}/unassign-group:
    post:
      tags: [ticket]
      summary: Gruppenzuweisung eines Tickets aufheben
      description: |
        Kein Request-Body. Setzt `assigned_to_group` auf `null` (im Unterschied
        zu `POST .../unassign`, das den Benutzer auf `""` statt `null` setzt).
        Erfordert Permission `support_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      responses:
        '200':
          description: Gruppenzuweisung aufgehoben
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/tickets/{id}/assign-user:
    post:
      tags: [ticket]
      summary: Ticket einem Benutzer zuweisen
      description: Inline validiert. Erfordert Permission `support_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id:
                  type: string
                  maxLength: 36
            example:
              user_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
      responses:
        '200':
          description: Benutzer zugewiesen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/tickets/{id}/cc-recipients:
    put:
      tags: [ticket]
      summary: CC-Empfaenger eines Tickets ersetzen
      description: |
        Vollstaendiger Ersatz der CC-Liste via `sync()`. Inline validiert.
        Erfordert Permission `support_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_ids]
              properties:
                user_ids:
                  type: array
                  items:
                    type: string
                    maxLength: 36
            example:
              user_ids: ["uid-1", "uid-2"]
      responses:
        '200':
          description: CC-Liste aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/tickets/bulk-delete:
    post:
      tags: [ticket]
      summary: Mehrere Tickets loeschen
      description: |
        Erfordert Permission `support_delete`. Antwort ist NICHT in `data`
        eingebettet, sondern nur `{"deleted": N}` mit der Anzahl tatsaechlich
        gefundener und geloeschter Tickets — unbekannte IDs werden still
        ignoriert (immer 200). `skipped`/`skipped_ids` aus dem geteilten
        `BulkDeleteResult`-Schema bleiben hier ungenutzt (`null`).
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/inventories/{mtag}/tickets:
    get:
      tags: [ticket]
      summary: Tickets eines Inventarobjekts auflisten
      description: |
        Das Geraet muss ueber dasselbe Kunden-Scoping wie
        `GET /inventories/{mtag}` sichtbar sein — sonst 404 ("Inventory not
        found.", IDOR-sicher). Kein Filter/Sort, immer
        `orderByDesc(created_at)`. Erfordert Permission `support_view`.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Tickets des Inventarobjekts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # TICKET COMMENTS
  # ──────────────────────────────────────────────
  /api/v2/tickets/{ticketId}/comments:
    get:
      tags: [ticket]
      summary: Kommentare eines Tickets auflisten
      description: |
        Kein Filter/Sort, immer `orderBy(posted, asc)` (aelteste zuerst — im
        Gegensatz zum Standard `-created_at` der Ticketliste). Abweichende
        Standard-Seitengroesse: **50** statt 25 (`page[size]` bleibt
        ueberschreibbar, max. 100). Erfordert Permission `support_view`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Kommentarliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TicketCommentResource'
                  meta:
                    type: object
                    properties:
                      total: { type: integer }
                      page: { type: integer }
                      per_page: { type: integer }
                      last_page: { type: integer }
                      api_version: { type: string }
                  links:
                    type: object
                    properties:
                      self: { type: string }
                      next: { type: string, nullable: true }
                      prev: { type: string, nullable: true }
              example:
                data:
                  - id: 7
                    type: ticket-comment
                    attributes:
                      ticket_id: 42
                      posted: "2026-08-03 09:15:00"
                      content: "Reproduced on 6.0.3, escalating."
                      content_markdown: null
                      first_comment: false
                    relationships:
                      author:
                        id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                        type: user
                        name: "Max Mustermann"
                meta:
                  total: 1
                  page: 1
                  per_page: 50
                  last_page: 1
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [ticket]
      summary: Kommentar zu einem Ticket erstellen
      description: |
        Server setzt `user_id`=Aufrufer, `posted`=jetzt, `first_comment=0` und
        aktualisiert `last_comment_id`/`updated_at` des Tickets. Erfordert
        Permission `support_edit`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketCommentInput'
            example:
              content: "Reproduced on 6.0.3, escalating."
      responses:
        '201':
          description: Kommentar erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketCommentResource'
              example:
                data:
                  id: 8
                  type: ticket-comment
                  attributes:
                    ticket_id: 42
                    posted: "2026-08-03 10:02:00"
                    content: "Reproduced on 6.0.3, escalating."
                    content_markdown: null
                    first_comment: false
                  relationships:
                    author:
                      id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                      type: user
                      name: "Max Mustermann"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/tickets/{ticketId}/comments/{commentId}:
    put:
      tags: [ticket]
      summary: Kommentar aktualisieren
      description: |
        Inline validiert (keine eigene FormRequest-Klasse). Der Kommentar muss
        zu `ticketId` gehoeren — ein `commentId` eines anderen Tickets liefert
        404 ("TicketComment not found."). Erfordert Permission `support_edit`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
        - name: commentId
          in: path
          required: true
          schema:
            type: integer
          example: 7
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketCommentInput'
            example:
              content: "Reproduced on 6.0.3 and 6.0.4, escalating to Tier 2."
      responses:
        '200':
          description: Kommentar aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TicketCommentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [ticket]
      summary: Kommentar loeschen
      description: |
        Loescht zugehoerige Aenderungen (`changes()`) zuerst. Gleiches
        Cross-Ticket-Scoping/404 wie beim Update. Erfordert Permission
        `support_edit`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
        - name: commentId
          in: path
          required: true
          schema:
            type: integer
          example: 7
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # TICKET ATTACHMENTS
  # ──────────────────────────────────────────────
  /api/v2/tickets/{ticketId}/attachments:
    get:
      tags: [ticket]
      summary: Anhaenge eines Tickets auflisten
      description: |
        Ohne `comment_id`: Ticket- UND Kommentar-Anhaenge zusammen. Mit
        `comment_id`: ersetzt die Basis-Query komplett, nur Anhaenge dieses
        Kommentars. Ticket-Anhaenge haben `link_table=ticket`/
        `link_id={ticketId}`, Kommentar-Anhaenge `link_table=ticket_comments`/
        `link_id={ticketId}-{commentId}`. Immer `orderBy(created, desc)`.
        Erfordert Permission `support_view`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
        - name: comment_id
          in: query
          description: Schraenkt auf Anhaenge dieses Kommentars ein
          schema:
            type: integer
          example: 7
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Anhangliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    type: document
                    attributes:
                      name: "Fehlerbild"
                      hash: "a1b2c3d4e5f6789012345678901234567890abcd"
                      major_version: 1
                      minor_version: 0
                      version: "1.0"
                      is_latest: true
                      is_existed: true
                      filename: "fehlerbild.png"
                      filetype: "png"
                      type: "ticket"
                      created: 1767434400
                      created_by: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                      link_table: "ticket"
                      link_id: "42"
                      report_id: null
                      status: 1
                      action_title: null
                      released_first: null
                    relationships:
                      creator:
                        data:
                          id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                          type: user
                          name: "Max Mustermann"
                      approver:
                        data: null
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [ticket]
      summary: Anhang zu einem Ticket hochladen
      description: |
        Multipart-Upload, max. 50 MB (51200 KB), gefaehrliche Dateiendungen
        werden blockiert. `comment_id` verknuepft den Anhang mit einem
        Kommentar statt mit dem Ticket direkt. Erfordert Permission
        `support_edit`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: "Max. 50 MB (51200 KB). Gefaehrliche Dateiendungen werden abgelehnt."
                comment_id:
                  type: integer
                  nullable: true
                  description: Verknuepft den Anhang mit diesem Kommentar statt mit dem Ticket
      responses:
        '201':
          description: Anhang hochgeladen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
              example:
                data:
                  id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  type: document
                  attributes:
                    name: "fehlerbild"
                    hash: "a1b2c3d4e5f6789012345678901234567890abcd"
                    major_version: 1
                    minor_version: 0
                    version: "1.0"
                    is_latest: true
                    is_existed: true
                    filename: "fehlerbild.png"
                    filetype: "png"
                    type: "ticket"
                    created: 1767434400
                    created_by: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                    link_table: "ticket"
                    link_id: "42"
                    report_id: null
                    status: 1
                    action_title: null
                    released_first: null
                  relationships:
                    creator:
                      data:
                        id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                        type: user
                        name: "Max Mustermann"
                    approver:
                      data: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Upload fehlgeschlagen. Verschachteltes Fehlerobjekt (analog
            401/403), NICHT das flache RFC7807-`ValidationError` — `title` ist
            hier "Upload Failed", nicht "Validation Error".
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Upload Failed"
                  detail: "Failed to upload attachment."

  /api/v2/tickets/{ticketId}/attachments/{documentId}/download:
    get:
      tags: [ticket]
      summary: Anhang herunterladen
      description: |
        Liefert die Datei als Binaerstream. Erfordert Permission
        `support_view`. Zwei unterschiedliche 404-Faelle: falsche
        Ticket-/Kommentar-Kombination liefert das flache RFC7807-`NotFound`
        ("Document not found."); existiert der Datensatz, fehlt aber die
        physische Datei auf der Platte, kommt stattdessen ein verschachteltes
        Fehlerobjekt (siehe Beispiel).
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
        - name: documentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Dateiinhalt
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Entweder flaches RFC7807 ("Document not found.", falsche
            Ticket-/Kommentar-Kombination) oder verschachteltes Fehlerobjekt
            (Datensatz existiert, physische Datei fehlt auf der Platte).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "Document not found."
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 404
                  title: "Not Found"
                  detail: "The attachment file could not be found on disk."

  /api/v2/tickets/{ticketId}/attachments/{documentId}:
    delete:
      tags: [ticket]
      summary: Anhang loeschen
      description: |
        Loescht die physische Datei nur, wenn kein anderer Document-Datensatz
        denselben Hash referenziert. 404 flach ("Document not found."), auch
        bei falscher Ticket-Zuordnung. Erfordert Permission `support_edit`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
        - name: documentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # TICKET ↔ INVENTORY LINKS
  # ──────────────────────────────────────────────
  /api/v2/tickets/{ticketId}/inventories:
    get:
      tags: [ticket]
      summary: Mit einem Ticket verknuepfte Inventarobjekte auflisten
      description: |
        Komplett handgebaut, KEINE Pagination (immer alle Zeilen). Sortiert
        nach `mtag` (dem rohen Verknuepfungsschluessel als String, NICHT nach
        `asset_number` oder Erstellungsreihenfolge). `meta` enthaelt nur
        `total`/`api_version`, kein `page`/`per_page`/`last_page`, keine
        `links`. Erfordert Permission `support_view`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
      responses:
        '200':
          description: Verknuepfte Inventarobjekte
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TicketInventoryLinkResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      api_version:
                        type: string
              example:
                data:
                  - ticket_id: 42
                    mtag: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    asset_number: "INV-4711"
                    serial_number: "SN-0815"
                    manufacturer: "ACME"
                    model: "DPI 620"
                    description: "Druckkalibrator"
                meta:
                  total: 1
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [ticket]
      summary: Inventarobjekt mit einem Ticket verknuepfen
      description: |
        `mtag` wird NICHT gegen die Inventar-Tabelle geprueft — jeder String
        bis 80 Zeichen wird akzeptiert. Antwort enthaelt nur
        `ticket_id`/`mtag`, kein `type`, kein `meta`. Erfordert Permission
        `support_edit`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mtag]
              properties:
                mtag:
                  type: string
                  maxLength: 80
            example:
              mtag: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
      responses:
        '201':
          description: Verknuepft
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ticket_id:
                        type: integer
                      mtag:
                        type: string
              example:
                data:
                  ticket_id: 42
                  mtag: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            Verknuepfung existiert bereits. Verschachteltes Fehlerobjekt
            (analog 401/403), NICHT der flache RFC7807-`Conflict`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: "Conflict"
                  detail: "This inventory item is already linked to this ticket."
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/tickets/{ticketId}/inventories/{mtag}:
    delete:
      tags: [ticket]
      summary: Inventar-Verknuepfung eines Tickets entfernen
      description: |
        Direkter `DB::table()`-Loeschvorgang mit Zeilenzahl-Pruefung (kein
        Eloquent-`ModelNotFound`-Pfad) — daher verschachteltes Fehlerobjekt
        statt des flachen RFC7807-`NotFound` bei fehlender Verknuepfung.
        Erfordert Permission `support_edit`.
      parameters:
        - name: ticketId
          in: path
          required: true
          schema:
            type: integer
          example: 42
        - $ref: '#/components/parameters/mtag'
      responses:
        '204':
          description: Erfolgreich entfernt
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Verknuepfung existiert nicht. Verschachteltes Fehlerobjekt (analog
            401/403), NICHT der flache RFC7807-`NotFound`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 404
                  title: "Not Found"
                  detail: "This inventory link does not exist."

  # ──────────────────────────────────────────────
  # TICKET METADATA CATALOGS (read-only)
  # ──────────────────────────────────────────────
  /api/v2/ticket-types:
    get:
      tags: [ticket]
      summary: Ticket-Typen auflisten
      description: |
        Read-only Metadatenkatalog, sortiert nach `title`. Kein Pagination-/
        Filter-/Sort-Parameter, immer vollstaendiger Dump. Flacher
        Zeilen-Dump ohne `attributes`-Wrapper — Felder: `id`, `title`, `alias`.
        Erfordert Permission `support_view`.
      responses:
        '200':
          description: Ticket-Typen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TicketMetadataResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  - id: 1
                    title: "Bug"
                    alias: "bug"
                meta:
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/ticket-categories:
    get:
      tags: [ticket]
      summary: Ticket-Kategorien auflisten
      description: |
        Read-only Metadatenkatalog, sortiert nach `title`. Kein Pagination-/
        Filter-/Sort-Parameter. Felder: `id`, `title`, `alias`. Erfordert
        Permission `support_view`.
      responses:
        '200':
          description: Ticket-Kategorien
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TicketMetadataResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  - id: 1
                    title: "Backend"
                    alias: "backend"
                meta:
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/ticket-priorities:
    get:
      tags: [ticket]
      summary: Ticket-Prioritaeten auflisten
      description: |
        Read-only Metadatenkatalog, sortiert nach `title`. Felder: `id`,
        `title`, `alias`, `background_color`, `color` (beide MIT fuehrendem
        `#`). Erfordert Permission `support_view`.
      responses:
        '200':
          description: Ticket-Prioritaeten
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TicketMetadataResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  - id: 1
                    title: "High"
                    alias: "high"
                    background_color: "#ff0000"
                    color: "#ffffff"
                meta:
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/ticket-versions:
    get:
      tags: [ticket]
      summary: Ticket-Versionen auflisten
      description: |
        Read-only Metadatenkatalog, sortiert nach `title`. Felder: `id`,
        `title`, `alias`, `description`, `risk`, `color` (BAR, ohne `#` — im
        Gegensatz zu Prioritaets-/Status-Farben), `user_id`, `created_at`,
        `updated_at`. Erfordert Permission `support_view`.
      responses:
        '200':
          description: Ticket-Versionen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TicketMetadataResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  - id: 3
                    title: "v2.0"
                    alias: "v2-0"
                    description: null
                    risk: 4
                    color: "FFFF00"
                    user_id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                    created_at: "2026-01-15T10:00:00.000000Z"
                    updated_at: "2026-01-15T10:00:00.000000Z"
                meta:
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/ticket-milestones:
    get:
      tags: [ticket]
      summary: Ticket-Meilensteine auflisten
      description: |
        Read-only Metadatenkatalog, sortiert nach `title`. Gleiche
        Feldstruktur wie `GET /ticket-versions` (`id`, `title`, `alias`,
        `description`, `risk`, `color` bar ohne `#`, `user_id`, `created_at`,
        `updated_at`). Erfordert Permission `support_view`.
      responses:
        '200':
          description: Ticket-Meilensteine
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TicketMetadataResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  - id: 6
                    title: "Sprint 12"
                    alias: "sprint-12"
                    description: null
                    risk: 3
                    color: "00FF00"
                    user_id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                    created_at: "2026-01-15T10:00:00.000000Z"
                    updated_at: "2026-01-15T10:00:00.000000Z"
                meta:
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/ticket-statuses:
    get:
      tags: [ticket]
      summary: Ticket-Statuswerte auflisten
      description: |
        Read-only, mappt die geteilte `status`-Tabelle (type=`support_tickets`)
        auf ein eigenes flaches Schema (`TicketStatusResource`, abweichend von
        den anderen 5 Metadaten-Katalogen). Sortiert nach `sort_order`.
        Erfordert Permission `support_view`.
      responses:
        '200':
          description: Ticket-Statuswerte
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TicketStatusResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  - id: "c6f35048-6556-5dba-602c-10eed9cb2473"
                    title: "Neu"
                    background_color: "#CE3535"
                    color: "#FFFFFF"
                  - id: "26ba494f-55c7-57fd-e66b-c967b54c5e5e"
                    title: "In Bearbeitung"
                    background_color: "#FF7F35"
                    color: "#FFFFFF"
                  - id: "7df66fec-6fb4-7a27-0db5-58ad7adffe19"
                    title: "Erledigt"
                    background_color: "#FDF400"
                    color: "#000000"
                  - id: "72ae067e-dc59-62fa-fda5-0da03186b554"
                    title: "Abgeschlossen"
                    background_color: "#00BD2C"
                    color: "#FFFFFF"
                meta:
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # SUPPORT PROJECTS
  # ──────────────────────────────────────────────
  /api/v2/projects:
    get:
      tags: [project]
      summary: Support-Projekte auflisten
      description: |
        Paginierte Projektliste. Erfordert Permission `support_view`. Ein
        unbekannter `sort`-Wert faellt still auf `title` aufsteigend zurueck.
      parameters:
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: status
          in: query
          description: "Exakter Filter: 0 = inaktiv, 1 = aktiv"
          schema:
            type: integer
            enum: [0, 1]
        - name: search
          in: query
          description: Teilstring-Suche (LIKE) auf `title`
          schema:
            type: string
          example: "Migration"
        - name: sort
          in: query
          description: "`id`, `title` (Standard, aufsteigend), `status`, `created_at`, `updated_at`. Praefix `-` fuer absteigend."
          schema:
            type: string
          example: "title"
      responses:
        '200':
          description: Projektliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectCollection'
              example:
                data:
                  - id: 4
                    type: project
                    attributes:
                      title: "Kalibrierlabor Migration"
                      alias: "kalibrierlabor-migration"
                      description: "A test project"
                      status: 1
                      created_at: "2026-08-03 09:00:00"
                      updated_at: null
                    relationships:
                      creator:
                        id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                        type: user
                        name: "Max Mustermann"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                links:
                  self: "/api/v2/projects?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [project]
      summary: Support-Projekt anlegen
      description: |
        `alias` wird bei Nichtangabe automatisch aus `title` slugifiziert.
        Server setzt `user_id`=Aufrufer, `created_at`=jetzt. Erfordert
        Permission `support_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectInput'
            example:
              title: "Kalibrierlabor Migration"
              description: "A test project"
              status: 1
      responses:
        '201':
          description: Projekt erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProjectResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/projects/{id}:
    get:
      tags: [project]
      summary: Einzelnes Support-Projekt abrufen
      description: Erfordert Permission `support_view`.
      parameters:
        - name: id
          in: path
          required: true
          description: Projekt-ID
          schema:
            type: integer
          example: 4
      responses:
        '200':
          description: Projekt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProjectResource'
              example:
                data:
                  id: 4
                  type: project
                  attributes:
                    title: "Kalibrierlabor Migration"
                    alias: "kalibrierlabor-migration"
                    description: "A test project"
                    status: 1
                    created_at: "2026-08-03 09:00:00"
                    updated_at: "2026-08-03 09:40:00"
                  relationships:
                    creator:
                      id: "a1b2c3d4-e5f6-7890-abcd-000000000001"
                      type: user
                      name: "Max Mustermann"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [project]
      summary: Support-Projekt aktualisieren
      description: |
        Partial Update — nur uebergebene Felder werden geaendert. Kein
        Auto-Slug- oder Default-Status-Fallback wie beim Anlegen. Server
        aktualisiert immer `updated_at`. Erfordert Permission `support_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 4
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectUpdateInput'
            example:
              description: "Aktualisierte Projektbeschreibung"
              status: 0
      responses:
        '200':
          description: Projekt aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProjectResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [project]
      summary: Support-Projekt loeschen
      description: |
        Kein Cascade/Guard: Tickets, die noch auf dieses Projekt zeigen,
        behalten danach ein verwaistes `project_id` — es wird kein 409
        ausgeloest. Erfordert Permission `support_delete`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          example: 4
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # INVENTORY — Navigation / Hierarchie / Statistik
  # ──────────────────────────────────────────────
  /api/v2/inventories/statistics:
    get:
      tags: [inventory]
      summary: Kennzahlen fuer das Inventar-Dashboard abrufen
      description: |
        Aggregierte Kennzahlen (Gesamtzahl, faellige/ueberfaellige
        Kalibrierungen). Keine Parameter. Erfordert Permission `inventory_view`.

        Faellig/ueberfaellig ist datumsbasiert (`next_calibration_date` der
        aktiven Kalibrierung, siehe `CalibrationStatusService`) — die
        Statusspalte traegt Status-uIDs und ist dafuer kein Kriterium.

        **Achtung Antwortform:** FLACH — kein `data`-Wrapper, keine
        JSON:API-Huelle.
      responses:
        '200':
          description: Kennzahlen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InventoryStatisticsResource'
              example:
                total: 812
                overdue_count: 14
                due_count: 37
                calibration_total: 2140
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/inventories/due-counts:
    get:
      tags: [inventory]
      summary: Geraetezahl je Faelligkeits-Topf abrufen
      description: |
        Anzahl der Geraete in jedem Faelligkeits-Topf (`overdue`, `due`,
        `critical_1..3`) — die Beschriftung der Umschalter im Arbeitsvorrat.
        Erfordert Permission `inventory_view`.

        Dieselben Datumsfenster wie `GET /dashboards/stats`
        (`CalibrationStatusService`), aber **auf die Sichtbarkeit des Benutzers
        eingeschraenkt** und mit `scope` steuerbar — damit die Zahl am
        Umschalter zur Laenge der Liste (`GET /inventories?due=`) passt.
        `/dashboards/stats` zaehlt dagegen installationsweit, weil geteilte
        Dashboards ohne Benutzerkontext ausgeliefert werden.

        **Achtung Antwortform:** FLACH — kein `data`-Wrapper, keine
        JSON:API-Huelle.
      parameters:
        - name: scope
          in: query
          description: |
            `mine` schraenkt auf die eigene Sichtbarkeit ein (immer erlaubt,
            da nur einschraenkend). `all` verlangt zusaetzlich die Permission
            `inventory_global`. Fehlt der Parameter, gilt der persistierte
            `enable_global`-Default des Benutzers — dieselbe Regel wie fuer die
            Liste.
          schema:
            type: string
            enum: [mine, all]
      responses:
        '200':
          description: Zahl je Topf
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InventoryDueCountsResource'
              example:
                overdue_count: 14
                due_count: 37
                critical_1_count: 21
                critical_2_count: 9
                critical_3_count: 4
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/inventories/roots:
    get:
      tags: [inventory]
      summary: Oberste Inventar-Ebene (ohne Eltern) abrufen
      description: |
        Liefert alle Inventarobjekte ohne `parent_asset_number`, sortiert nach
        `asset_number`. Nicht paginiert — Datenquelle der obersten Ebene der
        V2-Baumansicht (TreeTable). Erfordert Permission `inventory_view`.
      parameters:
        - name: scope
          in: query
          description: |
            `mine` schraenkt auf die eigene Sichtbarkeit ein (immer erlaubt,
            da nur einschraenkend). `all` verlangt zusaetzlich die Permission
            `inventory_global`. Fehlt der Parameter, gilt der persistierte
            `enable_global`-Default des Benutzers.
          schema:
            type: string
            enum: [mine, all]
      responses:
        '200':
          description: Oberste Inventar-Ebene
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InventoryResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    type: inventory
                    attributes:
                      asset_number: "DMM-001"
                      serial_number: "SN-1234567890"
                      description: "Digital Multimeter"
                      manufacturer: "FLUKE"
                      model: "179"
                      location: "Labor A"
                      status: 1
                      calibration_interval: 12
                      customer_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/inventories/tree:
    get:
      tags: [inventory]
      summary: Hierarchieansicht abrufen (Baum-Datenquelle)
      description: |
        Einzige Datenquelle der Hierarchieansicht. Erfordert Permission
        `inventory_view`.

        Ohne aktiven Filter wird eine einzelne Ebene "lazy" geladen
        (Wurzelknoten, oder Kinder von `?parent=<mtag>`); mit aktivem Filter
        wird der komplette gepruente Wald geliefert — jeder Treffer plus die
        Elternkette, die ihn einordnet, damit ein Treffer in Tiefe 5
        erreichbar bleibt statt nur zu existieren. Aktiv wird der Filter durch
        `search`, `advanced_filter`, `test_standards`, `categories`, `due`
        oder ein nicht-leeres `filter[...]`.

        Filter/Sortierung sind ansonsten identisch zu `GET /inventories`
        (`filter[feld]`, `filter_join[feld]`), ergaenzt um `search`
        (spaltenuebergreifende Schnellsuche ueber asset_number, description,
        serial_number, manufacturer, model).

        `pinned=<id>,<id>` (max. 50) haelt benannte Geraete im Ergebnis,
        unabhaengig vom Filter — so laesst sich eine Auswahl ueber mehrere
        Suchen hinweg zusammenstellen. Die tatsaechlich aufgeloesten Ids
        kommen in `meta.pinned` zurueck.

        Die Verschachtelung kommt als Id-Skelett in `meta.tree` zurueck
        (siehe `InventoryTreeNodeResource`) — nicht aus den `data`-Attributen
        ableitbar.
      parameters:
        - name: "filter[asset_number]"
          in: query
          description: "Wie bei `GET /inventories` (unterstuetzt `like:` Operator)"
          schema:
            type: string
        - name: "filter_join[asset_number]"
          in: query
          schema:
            type: string
            enum: [and, or]
        - name: sort
          in: query
          schema:
            type: string
        - name: search
          in: query
          description: Spaltenuebergreifende Schnellsuche (asset_number, description, serial_number, manufacturer, model)
          schema:
            type: string
          example: "fluke"
        - name: test_standards
          in: query
          description: Kommaseparierte Pruefnorm-UUIDs (ODER-verknuepft)
          schema:
            type: string
        - name: categories
          in: query
          description: Kommaseparierte Kategorie-UUIDs (ODER-verknuepft, Teilbaum-inklusiv)
          schema:
            type: string
        - name: due
          in: query
          schema:
            type: string
            enum: [overdue, due, critical_1, critical_2, critical_3]
        - name: advanced_filter
          in: query
          description: "Beliebiger nicht-leerer Wert schaltet in den gefilterten Modus (Praesenz zaehlt, nicht der Inhalt)"
          schema:
            type: string
        - name: only_hierarchy
          in: query
          description: Nur Geraete beruecksichtigen, die als Container/Baugruppe markiert sind oder Kinder haben
          schema:
            type: boolean
        - name: with_children
          in: query
          schema:
            type: boolean
            default: true
        - name: pinned
          in: query
          description: Kommaseparierte Ids, max. 50 — bleiben im Ergebnis unabhaengig vom Filter
          schema:
            type: string
        - name: parent
          in: query
          description: "MTAG — im ungefilterten Browse-Modus: Kinder dieses Knotens statt Wurzelknoten"
          schema:
            type: string
        - name: scope
          in: query
          description: "`mine`/`all` (siehe `GET /inventories/roots`)"
          schema:
            type: string
            enum: [mine, all]
      responses:
        '200':
          description: Baumdaten (immer 200, eigenes Format — kein `InventoryCollection`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InventoryResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      matches:
                        type: integer
                        description: Anzahl Treffer des Filters (ohne Kontext-/Pin-Zeilen)
                      filtered:
                        type: boolean
                        description: "true, wenn ein Filter aktiv war (gepruenter Wald statt Lazy-Ebene)"
                      truncated:
                        type: boolean
                        description: "true, wenn `limit` erreicht wurde"
                      limit:
                        type: integer
                        example: 500
                      pinned:
                        type: array
                        description: Tatsaechlich aufgeloeste Pin-Ids (Teilmenge von `?pinned=`)
                        items:
                          type: string
                      tree:
                        type: array
                        description: Id-Skelett der Verschachtelung
                        items:
                          $ref: '#/components/schemas/InventoryTreeNodeResource'
              example:
                data:
                  - id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    type: inventory
                    attributes:
                      asset_number: "DMM-001"
                      serial_number: "SN-1234567890"
                      description: "Digital Multimeter"
                      manufacturer: "FLUKE"
                      model: "179"
                      location: "Labor A"
                      status: 1
                      calibration_interval: 12
                      customer_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                meta:
                  total: 42
                  matches: 5
                  filtered: true
                  truncated: false
                  limit: 500
                  pinned: ["ea79b775-8b4b-8e17-98c9-b33e7065ff6f"]
                  tree:
                    - id: "b6a1c2d3-4567-8901-abcd-ef1234567890"
                      is_match: true
                      is_pinned: false
                      has_children: true
                      child_count: 3
                      is_container: true
                      children:
                        - id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                          is_match: false
                          is_pinned: false
                          has_children: false
                          child_count: 0
                          is_container: false
                          children: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/inventories/{mtag}/children:
    get:
      tags: [inventory]
      summary: Direkte Kind-Inventarobjekte auflisten
      description: |
        Liefert die direkten Kinder (`parent_asset_number` = Inventarnummer
        dieses Objekts). Nicht paginiert. Erfordert Permission `inventory_view`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Kind-Inventarobjekte
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InventoryResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [inventory]
      summary: Inventarobjekte als Kinder zuordnen
      description: |
        Setzt `parent_asset_number` der angegebenen Objekte auf die
        Inventarnummer dieses (Eltern-)Objekts. Selbstzuordnung und Zyklen
        werden still uebersprungen (zaehlen als `skipped`, kein Fehler pro
        Element). `data`/`meta.total` spiegeln den VOLLSTAENDIGEN aktuellen
        Kind-Bestand nach der Operation, nicht nur die neu zugeordneten
        Objekte. Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [children]
              properties:
                children:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    maxLength: 80
                  description: |
                    Ids der zuzuordnenden Kinder. Nicht als UUID validiert —
                    legacy V1-Schluessel koennen laenger sein.
            example:
              children: ["dmm-001-uuid", "tc-002-uuid"]
      responses:
        '200':
          description: Zugeordnet (vollstaendiger Kind-Bestand nach der Operation)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InventoryResource'
                  meta:
                    type: object
                    properties:
                      assigned:
                        type: integer
                      skipped:
                        type: integer
                        description: Selbstzuordnung/Zyklen, still uebersprungen (kein Fehler)
                      total:
                        type: integer
                        description: Vollstaendiger Kind-Bestand nach der Operation
              example:
                data: []
                meta:
                  assigned: 2
                  skipped: 0
                  total: 2
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Zwei Formen moeglich, unterschieden per `Content-Type`: eine
            Geschaeftsregel-Verletzung — Eltern-Objekt hat keine
            Inventarnummer (verschachteltes `error`-Objekt, `application/json`)
            — oder eine Formularvalidierung, `children` fehlt/leer (RFC7807,
            `application/problem+json`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: Validation Error
                  detail: "Parent inventory has no asset number and cannot take child inventories."
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  children:
                    - "The children field is required."

    delete:
      tags: [inventory]
      summary: Kind-Zuordnung entfernen
      description: |
        Setzt `parent_asset_number` der angegebenen Objekte auf `null`. Ids,
        die aktuell kein Kind dieses Objekts sind, werden still ignoriert
        (kein Fehler). Antwort spiegelt den vollstaendigen Kind-Bestand NACH
        der Operation. Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [children]
              properties:
                children:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    maxLength: 80
            example:
              children: ["dmm-001-uuid"]
      responses:
        '200':
          description: Entfernt (vollstaendiger Kind-Bestand nach der Operation)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InventoryResource'
                  meta:
                    type: object
                    properties:
                      removed:
                        type: integer
                      total:
                        type: integer
                        description: Vollstaendiger Kind-Bestand nach der Operation
              example:
                data: []
                meta:
                  removed: 1
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/hierarchy:
    get:
      tags: [inventory]
      summary: Vollstaendige Hierarchie (Eltern + Kinder) abrufen
      description: |
        Liefert die Elternkette (wurzel-zuerst, `ancestors`, max. Tiefe 20)
        sowie die direkten Kinder (`children`, nicht rekursiv) in einem
        Aufruf. Kein `meta`. Erfordert Permission `inventory_view`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Hierarchie
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryHierarchyResource'
              example:
                data:
                  id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                  ancestors:
                    - id: "b6a1c2d3-4567-8901-abcd-ef1234567890"
                      type: inventory
                      attributes:
                        asset_number: "RACK-01"
                        description: "19-Zoll-Schrank"
                  children: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/parent:
    get:
      tags: [inventory]
      summary: Eltern-Inventarobjekt abrufen
      description: |
        Liefert das Inventarobjekt, dessen Inventarnummer dem
        `parent_asset_number` dieses Objekts entspricht. Erfordert Permission
        `inventory_view`.

        **Kein Elternobjekt ist kein Fehler:** Existiert keine Zuordnung oder
        laesst sie sich nicht aufloesen, antwortet der Endpunkt mit HTTP 200
        und `{"data": null}` — NICHT 404. 404 tritt ausschliesslich auf, wenn
        `{mtag}` selbst ungueltig ist.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: "Eltern-Objekt, oder `null` wenn keines existiert"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    nullable: true
                    allOf:
                      - $ref: '#/components/schemas/InventoryResource'
              examples:
                mit_eltern:
                  summary: Mit Elternobjekt
                  value:
                    data:
                      id: "b6a1c2d3-4567-8901-abcd-ef1234567890"
                      type: inventory
                      attributes:
                        asset_number: "RACK-01"
                        description: "19-Zoll-Schrank"
                ohne_eltern:
                  summary: Ohne Elternobjekt (HTTP 200, nicht 404)
                  value:
                    data: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/trace:
    get:
      tags: [inventory]
      summary: Kalibrier-Rueckverfolgbarkeit (Normalkette) abrufen
      description: |
        Bidirektionale Rueckverfolgbarkeit ueber die `standards`-Zuordnungen.
        Erfordert Permission `inventory_view`.

        `direction=forward` (Standard): Inventarobjekte, die UNTER
        VERWENDUNG von `{mtag}` als Normal kalibriert wurden.
        `direction=backward`: Normale/Referenzen, mit denen `{mtag}` selbst
        kalibriert wurde.

        Paginiert wie `GET /inventories` (`filter[...]`, `sort`,
        `page[size]`, `page[number]`). **Eigenheit:** Die Antwort nutzt
        dieselbe `InventoryCollection` wie die Hauptliste, daher ist
        `meta.aggregations` immer vorhanden und immer leer (`[]`) — hier wird
        `withAggregations()` nie aufgerufen.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: direction
          in: query
          schema:
            type: string
            enum: [forward, backward]
            default: forward
        - name: "filter[asset_number]"
          in: query
          schema:
            type: string
        - name: sort
          in: query
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Normalkette (paginiert)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InventoryCollection'
              example:
                data:
                  - id: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
                    type: inventory
                    attributes:
                      asset_number: "REF-100"
                      description: "Referenznormal 100 Ohm"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  aggregations: []
                links:
                  self: "/api/v2/inventories/ea79b775-8b4b-8e17-98c9-b33e7065ff6f/trace?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/audits:
    get:
      tags: [inventory]
      summary: Aenderungsverlauf eines Inventarobjekts abrufen
      description: |
        Erfordert Permission `inventory_view`. Liefert `AuditCollection`-
        Eintraege mit `table=inventory`, `dataset_id={mtag}`, absteigend nach
        `timestamp`.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: "filter[field_name]"
          in: query
          schema:
            type: string
            maxLength: 50
        - name: "filter[type]"
          in: query
          description: "V1-Aktionstyp, z.B. `change_inventory_field`"
          schema:
            type: string
            maxLength: 50
        - name: "filter[from]"
          in: query
          description: Unix-Zeitstempel (Bereichsanfang)
          schema:
            type: integer
        - name: "filter[to]"
          in: query
          description: Unix-Zeitstempel (Bereichsende)
          schema:
            type: integer
      responses:
        '200':
          description: Audit-Eintraege des Inventarobjekts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditCollection'
              example:
                data:
                  - id: "a1b2c3d4-5717-4562-b3fc-2c963f66a001"
                    type: audit
                    attributes:
                      timestamp: 1767434400
                      user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                      type: "change_inventory_field"
                      field_name: "location"
                      title: null
                      data_old: "Labor A"
                      data_new: "Labor B"
                      table: "inventory"
                      dataset_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      user_label: "Max Mustermann"
                      dataset_label: "DMM-001"
                      field_label: "Standort"
                      data_old_label: "Labor A"
                      data_new_label: "Labor B"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/inventories/{mtag}/qrcode:
    get:
      tags: [inventory]
      summary: QR-Code fuer ein Inventarobjekt erzeugen
      description: |
        Erfordert Permission `inventory_view`. Akzeptiert sowohl die V2-UUID
        als auch einen aufgedruckten V1-Legacy-Tag als `{mtag}`.

        Kodiert einen OEFFENTLICHEN, NICHT authentifizierten Deep-Link
        `{base_url}/v2/qr/{mtag}` (der QR-Endpunkt selbst verlangt trotzdem
        Bearer-Token + `inventory_view` — nur die verlinkte Zielseite ist
        oeffentlich).
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: base_url
          in: query
          description: "Host-Override fuer den eingebetteten Deep-Link (Standard: `app.url`)"
          schema:
            type: string
      responses:
        '200':
          description: |
            QR-Code als SVG. `Content-Type: image/svg+xml`,
            `Cache-Control: public, max-age=86400`.
          content:
            image/svg+xml:
              schema:
                type: string
                description: Roher SVG-Markup
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # INVENTORY — Duplizieren & Status-Uebergaenge
  # ──────────────────────────────────────────────
  /api/v2/inventories/{mtag}/duplicate:
    post:
      tags: [inventory]
      summary: Inventarobjekt duplizieren
      description: |
        Kopiert alle Attribute unter neuer UUID (Id selbst wird nicht
        uebernommen). Kein Request-Body. Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '201':
          description: Kopie erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            Nur wenn die Property-Einstellung `disable_double_asset_number`
            aktiv ist UND die Inventarnummer der Quelle bereits bei einem
            anderen Objekt existiert (verschachteltes `error`-Objekt).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: Conflict
                  detail: "Duplicate asset numbers are not allowed. Change the asset number before duplicating."

  /api/v2/inventories/status-batch:
    post:
      tags: [inventory]
      summary: Status mehrerer Inventarobjekte in einem Aufruf aendern
      description: |
        Fuehrt fuer jede Id einzeln `StatusTransitionService::transition()`
        aus und sammelt Erfolge/Fehlschlaege — Datenquelle des V1
        "Status-Reader"-Workflows. Erfordert Permission `inventory_edit`.

        **Antwortet immer mit HTTP 200**, auch bei Teil-/Vollfehlschlag —
        Details stehen pro Element in `data[]`. Ids ausserhalb der
        Sichtbarkeit des Aufrufers werden wie nicht-existente Ids als
        `"Inventory not found."` gemeldet (kein IDOR-Leak).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids, status]
              properties:
                ids:
                  type: array
                  minItems: 1
                  items:
                    type: string
                  description: Inventar-Ids
                status:
                  type: string
                  maxLength: 36
                  description: Ziel-Status-UUID
                fields:
                  type: object
                  additionalProperties: true
                  description: Optionale Feldwerte, angewendet auf JEDES erfolgreich uebergegangene Element
            example:
              ids: ["ea79b775-8b4b-8e17-98c9-b33e7065ff6f", "3fa85f64-5717-4562-b3fc-2c963f66afa6"]
              status: "uuid-status-active"
              fields:
                location: "Labor B"
      responses:
        '200':
          description: Ergebnis je Element (immer 200)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InventoryStatusBatchResultResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
                        example: "2.0"
                      total:
                        type: integer
                      success:
                        type: integer
                      failed:
                        type: integer
              example:
                data:
                  - id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    success: true
                    transition:
                      applied_defaults: {}
                      cleared_fields: []
                      counter_updated: null
                  - id: "bad-id"
                    success: false
                    error: "Inventory not found."
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    success: false
                    error: "Mandatory fields missing."
                    required_fields: ["serial_number"]
                meta:
                  api_version: "2.0"
                  total: 3
                  success: 1
                  failed: 2
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/inventories/{mtag}/status:
    post:
      tags: [inventory]
      summary: Status-Uebergang durchfuehren
      description: |
        Fuehrt Pflichtfeld-Pruefung, Feld-Defaults (DefaultValue/EmptyNow) und
        Zaehler-Update des Zielstatus aus (`StatusTransitionService`).
        Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  maxLength: 36
                  description: Ziel-Status-UUID
            example:
              status: "uuid-status-active"
      responses:
        '200':
          description: Uebergang erfolgreich
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        $ref: '#/components/schemas/InventoryTransitionMeta'
              example:
                data:
                  id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                  type: inventory
                  attributes:
                    asset_number: "DMM-001"
                    status: 1
                meta:
                  transition:
                    success: true
                    applied_defaults: {}
                    cleared_fields: []
                    counter_updated: null
                    reload_required: false
                    follow_up_actions: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Zwei Formen moeglich, unterschieden per `Content-Type`: fehlende
            Pflichtfelder des Zielstatus — eigenes Workflow-Signal, weder
            `ErrorResponse` noch `ProblemDetails` (`application/json`) — oder
            eine unbekannte/inaktive `status`-UUID (RFC7807,
            `application/problem+json`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success:
                        type: boolean
                        enum: [false]
                      required_fields_missing:
                        type: boolean
                        enum: [true]
                      required_fields:
                        type: array
                        items:
                          type: string
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  success: false
                  required_fields_missing: true
                  required_fields: ["serial_number"]
                meta:
                  api_version: "2.0"
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: 'Status "uuid-unknown" not found for type "inventory"'
                errors:
                  status:
                    - 'Status "uuid-unknown" not found for type "inventory"'

  /api/v2/inventories/{mtag}/additional-fields:
    post:
      tags: [inventory]
      summary: Fehlende Pflichtfelder nachreichen und Status-Uebergang abschliessen
      description: |
        Wird typischerweise direkt nach einer 422-Antwort (fehlende
        Pflichtfelder) von `POST .../status` aufgerufen: `status` ist erneut
        das urspruengliche Ziel, `fields` die zuvor fehlenden Werte.
        Erfordert Permission `inventory_edit`.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, fields]
              properties:
                status:
                  type: string
                  maxLength: 36
                  description: Ziel-Status-UUID (identisch zum vorherigen Versuch)
                fields:
                  type: object
                  additionalProperties: true
                  description: Die zuvor fehlenden Feldwerte
            example:
              status: "uuid-status-active"
              fields:
                serial_number: "SN-1234567890"
                cost_center: "K-100"
      responses:
        '200':
          description: Gespeichert, Uebergang erfolgreich — identische Form zu `POST .../status`
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        $ref: '#/components/schemas/InventoryTransitionMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Identische Doppelform wie `POST .../status`: weiterhin fehlende
            Pflichtfelder (`application/json`, Workflow-Signal) oder unbekannte
            `status`-UUID (RFC7807, `application/problem+json`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success:
                        type: boolean
                        enum: [false]
                      required_fields_missing:
                        type: boolean
                        enum: [true]
                      required_fields:
                        type: array
                        items:
                          type: string
              example:
                data:
                  success: false
                  required_fields_missing: true
                  required_fields: ["cost_center"]
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: 'Status "uuid-unknown" not found for type "inventory"'
                errors:
                  status:
                    - 'Status "uuid-unknown" not found for type "inventory"'

  # ──────────────────────────────────────────────
  # INVENTORY — Sammelanlage / Scan-Aufloesung / Sammelloeschung
  # ──────────────────────────────────────────────
  /api/v2/inventories/bulk-preview:
    post:
      tags: [inventory]
      summary: Seriennummern-Bereich fuer Sammelanlage vorschauen
      description: |
        Erweitert einen Seriennummern-Bereich (Schnelleingabe) und markiert
        bereits existierende Seriennummern, bevor tatsaechlich gespeichert
        wird. Erfordert Permission `inventory_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [serial_start, count]
              properties:
                serial_start:
                  type: string
                  maxLength: 255
                count:
                  type: integer
                  minimum: 1
                  maximum: 100
            example:
              serial_start: "SN-00100"
              count: 2
      responses:
        '200':
          description: Vorschau
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryBulkPreviewResource'
              example:
                data:
                  type: inventory-bulk-preview
                  attributes:
                    items:
                      - serial_number: "SN-00100"
                        duplicate: false
                      - serial_number: "SN-00101"
                        duplicate: true
                    total: 2
                    duplicates_total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/inventories/bulk:
    post:
      tags: [inventory]
      summary: Mehrere Inventarobjekte in einem Aufruf anlegen (Schnelleingabe)
      description: |
        Legt bis zu 100 Objekte aus `items[]` an. Am obersten Level angegebene
        Felder (`device_type_id`, `location`, `description`, `status`,
        `customer_id`) sind geteilte Defaults, die in jedes Element gemischt
        werden — ein Element-Wert gewinnt gegenueber dem Default.
        `device_type_id` kopiert zusaetzlich `manufacturer`/`model`/
        `type_code` des Geraetetyps in jedes Element. Erfordert Permission
        `inventory_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    properties:
                      serial_number:
                        type: string
                        maxLength: 255
                      asset_number:
                        type: string
                        maxLength: 255
                        description: Faellt bei leerem Wert auf `serial_number` zurueck
                      location:
                        type: string
                        maxLength: 255
                      description:
                        type: string
                        maxLength: 255
                      status:
                        type: string
                        maxLength: 100
                        description: Ziel-Status-UUID
                      customer_id:
                        type: string
                        maxLength: 36
                device_type_id:
                  type: string
                  maxLength: 36
                  description: Kopiert manufacturer/model/type_code als geteilten Default in jedes Element
                location:
                  type: string
                  maxLength: 255
                  description: Geteilter Default, Element-Wert gewinnt
                description:
                  type: string
                  maxLength: 255
                status:
                  type: string
                  maxLength: 100
                  description: Geteilter Default (Ziel-Status-UUID)
                customer_id:
                  type: string
                  maxLength: 36
                skip_duplicates:
                  type: boolean
                  default: true
                  description: "true (Standard): existierende `serial_number` wird uebersprungen statt Fehler zu werfen"
                category_uID:
                  type: string
                  maxLength: 36
                  description: Wird auf alle angelegten Objekte angewendet
            example:
              items:
                - serial_number: "SN-00100"
                - serial_number: "SN-00101"
                  location: "Labor B"
              device_type_id: "b3e1c2d3-4567-8901-abcd-ef1234567890"
              location: "Labor A"
              status: "uuid-status-active"
              skip_duplicates: true
      responses:
        '201':
          description: |
            Angelegte Objekte. **Achtung:** vereinfachte Attributform
            (`toApiArray()`-Rohattribute) — ohne `relationships` und ohne
            aufgeloeste `*_label`-Felder, anders als `InventoryResource`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InventoryBulkResultResource'
                  meta:
                    type: object
                    properties:
                      created:
                        type: integer
                      skipped:
                        type: integer
              example:
                data:
                  - id: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
                    type: inventory
                    attributes:
                      asset_number: "SN-00100"
                      serial_number: "SN-00100"
                      location: "Labor A"
                      manufacturer: "Fluke"
                meta:
                  created: 1
                  skipped: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/inventories/scan-resolve:
    post:
      tags: [inventory]
      summary: Gescannte Codes zu Inventarobjekten aufloesen
      description: |
        **Erfordert Permission `inventory_view`** (nicht `inventory_edit`) —
        reine Leseoperation trotz POST-Verb, da der Codes-Body ueber
        GET-Query-Limits hinausgehen kann.

        Loest exakt per `asset_number` auf, behaelt die Scan-Reihenfolge bei,
        dedupliziert wiederholte Codes und ist nach Sichtbarkeit gescoped —
        Codes ausserhalb der Sichtbarkeit werden wie nicht-existente als
        "nicht gefunden" gemeldet (kein IDOR-Leak).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [codes]
              properties:
                codes:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                    maxLength: 255
                    nullable: true
            example:
              codes: ["DMM-001", "UNKNOWN-9", "DMM-001"]
      responses:
        '200':
          description: Aufloesungsergebnis je (deduplizierter) Code
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryScanResolveResource'
              example:
                data:
                  type: inventory-scan-resolve
                  attributes:
                    items:
                      - code: "DMM-001"
                        found: true
                        inventory:
                          id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                          type: inventory
                          attributes:
                            asset_number: "DMM-001"
                            serial_number: "SN-1234567890"
                            manufacturer: "FLUKE"
                            model: "179"
                      - code: "UNKNOWN-9"
                        found: false
                    found_total: 1
                    not_found_total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/inventories/bulk-delete:
    post:
      tags: [inventory]
      summary: Mehrere Inventarobjekte in einem Aufruf loeschen
      description: |
        Erfordert Permission `inventory_delete`. Objekte mit zugehoerigen
        Kalibrierungen werden uebersprungen (wie bei `DELETE .../{mtag}`),
        alle anderen ungeprueft geloescht. `deleted` zaehlt alle angefragten
        Ids ohne verknuepfte Kalibrierungen — unabhaengig davon, ob die Id
        tatsaechlich existierte (kein Vorab-Existenzcheck).
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # INVENTORY — Tabellenimport (Excel/CSV)
  # ──────────────────────────────────────────────
  /api/v2/inventories/import-preview:
    post:
      tags: [inventory]
      summary: Tabellenimport vorschauen (Spalten-Mapping + Klassifizierung)
      description: |
        Liest die hochgeladene Datei, loest das Spalten-Mapping auf und
        klassifiziert jede Zeile als neu/Update/uebersprungen — ohne zu
        speichern. Erfordert Permission `inventory_edit`.

        `rows` ist IMMER auf max. 50 Eintraege gekappt, unabhaengig von
        `total` (siehe `meta.truncated` bzw. `attributes.truncated`).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: Excel/CSV-Datei (`xlsx`, `xls`, `csv`), max. 10 MB (10240 KB)
                mapping:
                  type: object
                  additionalProperties:
                    type: string
                    nullable: true
                  description: |
                    Optionales Spalten-Mapping-Override: Datei-Header zu
                    `entity:api_name` (z.B.
                    `{"Seriennummer":"inventory:serial_number"}`). Ein
                    leerer/`null`-Wert erzwingt, dass die Spalte ignoriert wird.
            example:
              mapping:
                Seriennummer: "inventory:serial_number"
                Kunde: "customer:name"
      responses:
        '200':
          description: Import-Vorschau
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryImportPreviewResource'
              example:
                data:
                  type: inventory-import-preview
                  attributes:
                    columns:
                      - header: "Seriennummer"
                        target: inventory
                        api_name: serial_number
                      - header: "Bemerkung"
                        target: null
                        api_name: null
                    unmapped_headers: ["Bemerkung"]
                    rows:
                      - values:
                          "inventory:serial_number": "SN-1234567890"
                          "customer:name": "Acme GmbH"
                        asset_number: "DMM-001"
                        exists: true
                        importable: true
                    total: 240
                    shown: 50
                    new: 180
                    update: 55
                    skipped: 5
                    truncated: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            RFC7807-Validierungsfehler, drei Faelle: Formularvalidierung
            (`file`-Extension/Groesse/Mimetype), eine nicht lesbare/korrupte
            Datei (`errors.file` mit dynamischer Meldung), oder der Rand-Fall
            "keine Datei hochgeladen" — dieser laeuft ueber ein generisches
            `abort()` statt die FormRequest-Validierung, daher
            `title: "Unprocessable Entity"` statt `"Validation Error"`.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                formularvalidierung:
                  summary: Formularvalidierung
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      file:
                        - "The file field must be a file of type: xlsx, xls, csv."
                datei_nicht_lesbar:
                  summary: Datei nicht lesbar/korrupt
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "Die Datei konnte nicht gelesen werden: Invalid file format."
                    errors:
                      file:
                        - "Die Datei konnte nicht gelesen werden: Invalid file format."
                keine_datei:
                  summary: Keine Datei hochgeladen (Rand-Fall)
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Unprocessable Entity
                    status: 422
                    detail: "Es wurde keine Datei hochgeladen."

  /api/v2/inventories/import:
    post:
      tags: [inventory]
      summary: Tabellenimport durchfuehren
      description: |
        Fuehrt den Import in einer Transaktion aus. Anders als bei der
        Vorschau ist `mapping` hier PFLICHT (min. 1 Eintrag). Erfordert
        Permission `inventory_edit`.

        **Zusaetzliche Modul-Permissions:** Zielt das Mapping auf
        `customer:`/`calibration:`/`category:`-Felder, verlangt der
        Endpunkt zusaetzlich `customers_edit`/`calibration_edit`/
        `category_edit` — sonst 403 (siehe unten).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, mapping]
              properties:
                file:
                  type: string
                  format: binary
                  description: Excel/CSV-Datei (`xlsx`, `xls`, `csv`), max. 10 MB (10240 KB) — gleiche Regeln wie bei der Vorschau
                mapping:
                  type: object
                  minProperties: 1
                  additionalProperties:
                    type: string
                    nullable: true
                  description: "Spalten-Mapping: Datei-Header zu `entity:api_name`"
                update_existing:
                  type: boolean
                  description: Vorhandene Datensaetze (per `asset_number`/`name`) aktualisieren statt nur anzulegen
            example:
              mapping:
                Seriennummer: "inventory:serial_number"
                Kunde: "customer:name"
              update_existing: true
      responses:
        '201':
          description: Import-Ergebnis
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/InventoryImportResultResource'
              example:
                data:
                  type: inventory-import-result
                  attributes:
                    customers_created: 3
                    customers_updated: 1
                    created: 180
                    updated: 55
                    calibrations_created: 40
                    calibrations_skipped: 2
                    categories_linked: 12
                    skipped: 5
                    skipped_rows: [7, 23, 41]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            Zwei Formen moeglich: fehlende Basis-Permission `inventory_edit`
            (Standard-Form, verschachteltes `error`-Objekt,
            `application/json`, siehe `#/components/responses/Forbidden`)
            oder eine zusaetzliche Modul-Permission fehlt, weil das Mapping
            `customer:`/`calibration:`/`category:`-Ziele enthaelt (RFC7807
            `application/problem+json`, aus einem generischen `abort()`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 403
                  title: Forbidden
                  detail: "Missing required permission: inventory_edit"
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/403"
                title: Forbidden
                status: 403
                detail: "Missing required permission: customers_edit"
        '422':
          description: |
            Gleiche drei Faelle wie bei `POST .../import-preview`
            (Formularvalidierung, nicht lesbare Datei, "keine Datei
            hochgeladen"), zusaetzlich fehlt hier `mapping` als
            Formularvalidierung, falls leer/nicht angegeben.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                formularvalidierung:
                  summary: Formularvalidierung
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      mapping:
                        - "The mapping field is required."
                datei_nicht_lesbar:
                  summary: Datei nicht lesbar/korrupt
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "Die Datei konnte nicht gelesen werden: Invalid file format."
                    errors:
                      file:
                        - "Die Datei konnte nicht gelesen werden: Invalid file format."
                keine_datei:
                  summary: Keine Datei hochgeladen (Rand-Fall)
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Unprocessable Entity
                    status: 422
                    detail: "Es wurde keine Datei hochgeladen."

  # ──────────────────────────────────────────────
  # INVENTORY — Berichte (Reports), mirrors CalibrationReportController
  # ──────────────────────────────────────────────
  /api/v2/inventories/{mtag}/reports:
    get:
      tags: [inventory]
      summary: Verfuegbare Berichte eines Inventarobjekts auflisten
      description: |
        Erfordert Permission `inventory_view`. Listet sichtbare
        `ReportSetting`-Zeilen (`grid_name=inventory`, `active=1`), gefiltert
        nach Rolle/Gruppe des Nutzers. Nicht paginiert.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Berichtsliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportSettingResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
                    type: report-setting
                    attributes:
                      button_name: "Datenblatt"
                      menu: print
                      format: pdf
                      folder_name: Inventar
                      report_name: "inventory_datasheet.jrxml"
                      is_label: false
                      is_print: true
                      email_attachment: true
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/reports/{reportId}/parameters:
    get:
      tags: [inventory]
      summary: Berichtsparameter abrufen
      description: |
        Erfordert Permission `inventory_view`. 404 auch dann, wenn der
        Bericht existiert, aber Rolle/Gruppe des Nutzers ihn nicht sehen darf
        (bewusst 404 statt 403, um die Existenz nicht zu bestaetigen).
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: reportId
          in: path
          required: true
          description: UUID des Berichts (`ReportSetting.uID`)
          schema:
            type: string
            format: uuid
          example: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
      responses:
        '200':
          description: Berichtsparameter (oft leer)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportParameterResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: Start_Date
                    type: report-parameter
                    attributes:
                      name: Start_Date
                      type: Date
                      default: null
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            `"Inventory not found."` bei ungueltigem `{mtag}`, sonst
            `"ReportSetting not found."` bei ungueltiger/inaktiver
            `{reportId}` — oder (wenn die ReportSetting existiert, aber fuer
            Rolle/Gruppe des Nutzers nicht sichtbar ist) die generische
            Fallback-Meldung `"The requested resource was not found."`.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "ReportSetting not found."

  /api/v2/inventories/{mtag}/reports/{reportId}/preview:
    get:
      tags: [inventory]
      summary: Bericht als PDF-Vorschau rendern
      description: |
        Erfordert Permission `inventory_view`. Rendert immer PDF, unabhaengig
        vom konfigurierten `format` der `ReportSetting`. Gleiche 404-Faelle
        wie `GET .../parameters`.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: reportId
          in: path
          required: true
          description: UUID des Berichts
          schema:
            type: string
            format: uuid
          example: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
        - name: "parameters[Name]"
          in: query
          description: "Laufzeit-Parameter, z.B. `parameters[Start_Date]=2026-01-01`"
          schema:
            type: string
      responses:
        '200':
          description: 'PDF-Vorschau (`Content-Disposition: inline`)'
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          description: Berichts-Engine konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  /api/v2/inventories/{mtag}/reports/generate:
    post:
      tags: [inventory]
      summary: Bericht generieren
      description: |
        Erfordert Permission `inventory_edit`. `part` wird validiert, aber
        von diesem Endpunkt nicht ausgewertet (harmloser No-Op — reserviert
        fuer andere Report-Controller mit Teilberichten). Hat die
        `ReportSetting` `save_to_dms=1`, wird das Ergebnis zusaetzlich
        automatisch im DMS abgelegt (Fehlschlag wird nur geloggt, bricht den
        Download nicht ab).
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  description: UUID des Berichts (`ReportSetting.uID`)
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                  description: "Standard: konfiguriertes Format der ReportSetting, sonst `pdf`"
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                  description: Akzeptiert, aber von diesem Endpunkt nicht ausgewertet
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
              format: pdf
      responses:
        '200':
          description: 'Berichtsdatei (`Content-Disposition: attachment`, `Cache-Control: no-cache, no-store, must-revalidate`, `Content-Type` je nach `format`)'
          content:
            application/pdf:
              schema:
                type: string
                format: binary
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema:
                type: string
                format: binary
            application/vnd.openxmlformats-officedocument.wordprocessingml.document:
              schema:
                type: string
                format: binary
            text/csv:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: Berichts-Engine konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  /api/v2/inventories/{mtag}/reports/send:
    post:
      tags: [inventory]
      summary: Bericht per E-Mail versenden
      description: |
        Erfordert Permission `inventory_edit`. Generiert den Bericht und
        uebergibt ihn dem Mailer. **Achtung:** Meldet der Mailer einen
        Fehlversand, antwortet der Endpunkt mit 502 und derselben
        `MailerMessageResource`-Form (`status: "failed"`, `error_message`
        gefuellt) statt eines generischen Fehlers.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id, to_address, subject, body]
              properties:
                report_id:
                  type: string
                to_address:
                  type: string
                  format: email
                  maxLength: 255
                subject:
                  type: string
                  maxLength: 255
                body:
                  type: string
                cc_address:
                  type: string
                  maxLength: 255
                  nullable: true
                bcc_address:
                  type: string
                  maxLength: 255
                  nullable: true
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                  description: Akzeptiert, aber nicht ausgewertet
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
              to_address: "customer@example.com"
              subject: "Datenblatt DMM-001"
              body: "Bitte finden Sie das Datenblatt im Anhang."
      responses:
        '201':
          description: Mailer hat den Versand angenommen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/MailerMessageResource'
              example:
                data:
                  id: "c1d2e3f4-5678-4a9b-8c0d-1e2f3a4b5c6d"
                  type: mailer-message
                  attributes:
                    status: sent
                    error_message: null
                    to_address: "customer@example.com"
                    subject: "Datenblatt DMM-001"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: |
            Entweder ein Berichts-Engine-Fehler (`ErrorResponse`,
            `title: "Report Generation Failed"`) oder der Mailer meldet einen
            Fehlversand (dieselbe `MailerMessageResource`-Form wie bei 201,
            aber `status: "failed"`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/MailerMessageResource'
              examples:
                report_engine_fehler:
                  summary: Berichts-Engine-Fehler
                  value:
                    error:
                      status: 502
                      title: Report Generation Failed
                      detail: "Report runner returned a non-success status."
                mailer_fehlversand:
                  summary: Mailer meldet Fehlversand
                  value:
                    data:
                      id: "c1d2e3f4-5678-4a9b-8c0d-1e2f3a4b5c6d"
                      type: mailer-message
                      attributes:
                        status: failed
                        error_message: "SMTP connect() failed."
                        to_address: "customer@example.com"
                        subject: "Datenblatt DMM-001"
        '503':
          description: Keine Mailer-Konfiguration fuer diese Installation hinterlegt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 503
                  title: Mailer Configuration Missing
                  detail: "No mailer is configured for this installation."

  /api/v2/inventories/{mtag}/reports/save-to-dms:
    post:
      tags: [inventory]
      summary: Bericht generieren und im DMS ablegen
      description: |
        Erfordert Permission `inventory_edit`. Bei `format=pdf` wird vor der
        Ablage zusaetzlich die Signaturerfassung angewendet
        (`ReportSignatureService`). Legt das Dokument verknuepft mit
        `link_table=inventory`, `link_id={mtag}` im DMS ab.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                file_setting_id:
                  type: string
                  description: UUID der Ziel-DMS-Ablage — ueberschreibt die auf der ReportSetting konfigurierte Ablage
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
              format: pdf
      responses:
        '201':
          description: Im DMS abgelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
              example:
                data:
                  id: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
                  type: document
                  attributes:
                    name: "Datenblatt_DMM-001"
                    hash: "a1b2c3d4e5f6"
                    major_version: 1
                    minor_version: 0
                    version: "1.0"
                    is_latest: true
                    is_existed: true
                    filename: "DMM-001.pdf"
                    filetype: pdf
                    type: inventory_doc
                    created: 1767434400
                    created_by: "45f09720-082f-5c74-9cfa-a605e2863904"
                    link_table: inventory
                    link_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
                    status: 0
                    action_title: null
                    released_first: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Entweder ein Validierungsfehler (RFC7807, `application/problem+json`)
            oder die DMS-Ablage ist fehlgeschlagen (verschachteltes
            `error`-Objekt mit zusaetzlichem `candidates`-Feld,
            `application/json`).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  report_id:
                    - "The report id field is required."
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      status:
                        type: integer
                      title:
                        type: string
                      detail:
                        type: string
                      candidates:
                        type: array
                        nullable: true
                        items:
                          type: string
              example:
                error:
                  status: 422
                  title: "DMS-Ablage fehlgeschlagen"
                  detail: "Kein passendes Dokumenten-Setting fuer diesen Bericht gefunden."
                  candidates: null
        '502':
          description: Berichts-Engine konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  /api/v2/inventories/{mtag}/reports/release:
    post:
      tags: [inventory]
      summary: Bericht generieren, ablegen und freigeben ("Freigeben")
      description: |
        Erfordert Permission `inventory_edit`. Erzeugt und archiviert EIN
        Dokument je passender `document_release`-Regel (rollen-/
        gruppenabhaengig), konvertiert jeweils in das von der Regel
        geforderte Format, wendet Signaturen an und legt jedes Dokument mit
        `approve: true` im DMS ab — bei mehreren passenden Regeln entsprechend
        mehrere Dokumente (bewusstes V1-Paritaetsverhalten).
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                file_setting_id:
                  type: string
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
      responses:
        '201':
          description: Freigegeben und im DMS abgelegt (ein Eintrag je passender Regel)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocumentResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
                    type: document
                    attributes:
                      name: "Datenblatt_DMM-001"
                      major_version: 1
                      minor_version: 0
                      version: "1.0"
                      is_latest: true
                      is_existed: true
                      filename: "DMM-001.pdf"
                      filetype: pdf
                      type: inventory_doc
                      created: 1767434400
                      link_table: inventory
                      link_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      report_id: "b6e1c9a0-6f21-4c8a-9e77-1a2b3c4d5e6f"
                      status: 1
                      action_title: "Freigegeben"
                      released_first: 1767434400
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Entweder keine Freigabe-Regel fuer Rolle/Gruppe des Nutzers
            konfiguriert (verschachteltes `error`-Objekt) oder DMS-Ablage
            fehlgeschlagen (dasselbe erweiterte `candidates`-Format wie bei
            `POST .../reports/save-to-dms`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Keine Freigabe konfiguriert"
                  detail: "Für diesen Bericht ist keine Freigabe-Regel für Ihre Rolle/Gruppe konfiguriert."
        '502':
          description: Berichts-Engine konnte den Bericht nicht erzeugen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "Report runner returned a non-success status."

  # ──────────────────────────────────────────────
  # INVENTORY — Proxy-Unterressourcen (Details siehe jeweilige Domaene)
  # ──────────────────────────────────────────────
  /api/v2/inventories/{mtag}/calibrations:
    get:
      tags: [inventory]
      summary: Kalibrierungen eines Inventarobjekts auflisten (Proxy)
      description: |
        Proxy auf die Calibration-Ressource (siehe `calibration`-Domaene),
        gefiltert auf `inventory_id={mtag}`. Paginiert, gleiche
        `filter[...]`/`sort`-Semantik wie `GET /calibrations`. Erfordert
        Permission `inventory_view`.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Kalibrierungen dieses Inventarobjekts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      description: Kalibrierungs-Ressource (siehe `calibration`-Domaene)
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
                  links:
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/inventories/{mtag}/locations:
    get:
      tags: [inventory]
      summary: Standort-/Buchungshistorie eines Inventarobjekts auflisten (Proxy)
      description: |
        Proxy auf die Location-Ressource (siehe `location`-Domaene),
        gefiltert auf `inventory_id={mtag}`. Paginiert, Standardsortierung:
        aktueller Standort zuerst, danach neueste Bewegung zuerst. Erfordert
        Permission `inventory_view`.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Standorthistorie dieses Inventarobjekts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      description: Location-Ressource (siehe `location`-Domaene)
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
                  links:
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/inventories/{mtag}/repairs:
    get:
      tags: [inventory]
      summary: Reparaturen eines Inventarobjekts auflisten (Proxy)
      description: |
        Proxy auf die Repair-Ressource (siehe `repair`-Domaene), gefiltert
        auf `inventory_id={mtag}`. Paginiert, gleiche `filter[...]`/`sort`-
        Semantik wie die Repair-Hauptliste. Erfordert Permission
        `inventory_view`.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Reparaturen dieses Inventarobjekts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      description: Repair-Ressource (siehe `repair`-Domaene)
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
                  links:
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/inventories/{mtag}/rented-dates:
    get:
      tags: [inventory]
      summary: Gebuchte/vermietete Zeitraeume eines Inventarobjekts abrufen
      description: |
        Belegte Zeitraeume aus der Standorthistorie (`location_date` bis
        `rental_end`), aufsteigend nach `from`. Nicht paginiert. Erfordert
        Permission `inventory_view`, sofern der Aufrufer nicht ohnehin alle
        Inventarobjekte sieht.
      parameters:
        - $ref: '#/components/parameters/mtag'
      responses:
        '200':
          description: Belegte Zeitraeume
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        location_id:
                          type: string
                        from:
                          type: string
                          format: date
                        to:
                          type: string
                          format: date
                        status:
                          type: string
                          nullable: true
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - location_id: "f1e2d3c4-b5a6-4978-8c9d-0e1f2a3b4c5d"
                    from: "2026-05-01"
                    to: "2026-05-10"
                    status: "1"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # DOCUMENTS (DMS)
  # ──────────────────────────────────────────────
  /api/v2/documents:
    get:
      tags: [document]
      summary: Dokumente auflisten
      description: |
        Paginierte Liste aller Dokumente (DMS). Erfordert Permission `dms_view`.

        `link_table`, `link_id`, `status`, `type` und `latest_only` sind
        einfache Query-Parameter (kein `filter[...]`-Wrapper). Zusaetzlich
        generische Filterung/Sortierung auf DMS-Rohspalten via
        `filter[feld]=op:wert` / `sort=feld` (siehe Filterung oben).

        **Quirk:** `latest_only` ist eine reine Presence-Pruefung — auch
        `?latest_only=0` filtert bereits auf `is_latest=1`.
      parameters:
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
        - name: link_table
          in: query
          description: Filter nach Verknuepfungstabelle
          schema:
            type: string
            enum: [inventory, calibration, booking, repair, types, wiki, specification, master_report_setting, master_report_template, ticket, ticket_comments, notepad]
        - name: link_id
          in: query
          description: Filter nach verknuepfter Datensatz-UUID
          schema:
            type: string
        - name: status
          in: query
          description: "Filter nach Freigabestatus (0 = nicht freigegeben, 1 = freigegeben)"
          schema:
            type: integer
            enum: [0, 1]
        - name: type
          in: query
          description: Filter nach Dokumentart (`file_setting.column_name`)
          schema:
            type: string
          example: check_cert
        - name: latest_only
          in: query
          description: |
            Wenn ueberhaupt gesetzt (auch als `latest_only=0`), wird zusaetzlich
            auf `is_latest=1` gefiltert — reine Presence-, keine Boolean-Pruefung.
          schema:
            type: string
      responses:
        '200':
          description: Dokumentliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
                    type: document
                    attributes:
                      name: "Kalibrierschein_DMM-001"
                      hash: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
                      major_version: 1
                      minor_version: 0
                      version: "1.0"
                      is_latest: true
                      is_existed: true
                      filename: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.pdf"
                      filetype: pdf
                      type: check_cert
                      created: 1767434400
                      created_by: "45f09720-082f-5c74-9cfa-a605e2863904"
                      link_table: inventory
                      link_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      report_id: null
                      status: 1
                      action_title: null
                      released_first: 1
                    relationships:
                      creator:
                        data:
                          id: "45f09720-082f-5c74-9cfa-a605e2863904"
                          type: user
                          name: "Max Mustermann"
                      approver: []
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0.0"
                links:
                  self: "/api/v2/documents?page[number]=1&page[size]=25"
                  first: "/api/v2/documents?page[number]=1&page[size]=25"
                  last: "/api/v2/documents?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [document]
      summary: Dokument hochladen
      description: |
        Laedt eine Datei in die DMS hoch (V1-kompatible Versionierung).
        Erfordert Permission `dms_edit`. Existiert bereits ein Dokument mit
        gleichem `name`+`link_table`+`type`, wird eine neue Minor-Version
        angelegt statt eines neuen Dokuments. Definiert die Zieldokumentart
        (`file_setting`) ein Namensmuster und wird kein `link_id` mitgegeben,
        muss der Dateiname dem Muster entsprechen (sonst 422).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: "Max. 100 MB (102400 KB). Gefaehrliche Endungen (php, exe, sh, bat, html, svg, js, ...) werden abgelehnt."
                name:
                  type: string
                  maxLength: 255
                  description: "Standard: Dateiname ohne Endung"
                  example: "Kalibrierschein_2026"
                type:
                  type: string
                  maxLength: 255
                  description: Dokumentart (`file_setting.column_name`)
                  example: inventory_doc
                link_table:
                  type: string
                  enum: [inventory, calibration, booking, repair, types, wiki, specification, master_report_setting, master_report_template, ticket, ticket_comments, notepad]
                link_id:
                  type: string
                  maxLength: 100
      responses:
        '201':
          description: Dokument gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            Zwei Fehlerformen moeglich: flache Validierung (fehlende/
            gefaehrliche Datei, RFC7807) ODER ein manuell gebauter,
            VERSCHACHTELTER Upload-Fehler (`title: "Upload Failed"`). Weitere
            verschachtelte `detail`-Texte: `"Failed to store file version in
            DMS."`, `"Der Dateiname entspricht nicht dem Zuordnungsmuster der
            Dokumentart."`, `"Zieldatensatz nicht gefunden – der Dateiname
            kann nicht aus dem Muster erzeugt werden."`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - $ref: '#/components/schemas/ProblemDetails'
              examples:
                upload_pipeline_failed:
                  summary: "Verschachtelt: Upload-Pipeline-Fehler"
                  value:
                    error:
                      status: 422
                      title: Upload Failed
                      detail: "Failed to store file in DMS."
                validation_error:
                  summary: "Flach: Datei-Validierung (RFC7807)"
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      file:
                        - "The file has a file type that is not allowed for security reasons."

  /api/v2/documents/{id}:
    get:
      tags: [document]
      summary: Einzelnes Dokument abrufen
      description: |
        Erfordert Permission `dms_view`. Laedt `creator` und `approver`.
        Zusaetzlich zur reinen Existenzpruefung greift die kategoriebasierte
        `DocumentVisibility`-ACL (nicht-freigegebene Versionen einer
        eingeschraenkten Kategorie sind nur fuer Rolle/Gruppe der Kategorie
        sichtbar) — beide Faelle liefern 404, siehe unten.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
      responses:
        '200':
          description: Dokument
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Entweder die ID existiert nicht (`"Document not found."`) ODER
            das Dokument existiert, ist aber wegen der Kategorie-ACL fuer
            diesen Nutzer nicht sichtbar (generische, absichtlich vom
            "existiert nicht"-Fall ununterscheidbare IDOR-Meldung).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                not_found:
                  summary: Dokument existiert nicht
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "Document not found."
                visibility_denied:
                  summary: Durch Kategorie-ACL verdeckt (IDOR-Schutz)
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "The requested resource was not found."

    delete:
      tags: [document]
      summary: Dokument loeschen
      description: |
        Erfordert Permission `dms_delete`. Loescht die physische Datei nur,
        wenn keine andere Dokument-Zeile denselben `hash` referenziert
        (Mehrfachversionen teilen sich eine Datei).
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/documents/{id}/download:
    get:
      tags: [document]
      summary: Dokumentdatei herunterladen
      description: |
        Erfordert Permission `dms_view`, unterliegt derselben Kategorie-ACL
        wie `GET /documents/{id}`. Enthaelt das PDF bereits erfasste
        DMS-Signaturen, werden sie beim Ausliefern eingebrannt
        (`Content-Type: application/pdf`); ansonsten wird die Rohdatei als
        `application/octet-stream` gestreamt (`Content-Disposition: attachment`).
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
      responses:
        '200':
          description: Dateiinhalt
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Drei Ursachen: ID existiert nicht (flach, `"Document not
            found."`), Kategorie-ACL verdeckt das Dokument (flach, generische
            IDOR-Meldung) — oder die Datenbankzeile existiert, aber die Datei
            fehlt physisch auf der Platte (VERSCHACHTELT, `title: "Not
            Found"`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ProblemDetails'
                  - $ref: '#/components/schemas/ErrorResponse'
              examples:
                not_found:
                  summary: "Flach: Dokument existiert nicht"
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "Document not found."
                visibility_denied:
                  summary: "Flach: durch Kategorie-ACL verdeckt (IDOR-Schutz)"
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "The requested resource was not found."
                file_missing_on_disk:
                  summary: "Verschachtelt: Datei fehlt physisch"
                  value:
                    error:
                      status: 404
                      title: Not Found
                      detail: "The document file could not be found on disk."

  /api/v2/documents/{id}/versions:
    get:
      tags: [document]
      summary: Alle Versionen eines Dokuments auflisten
      description: |
        Erfordert Permission `dms_view`. NICHT paginiert (alle Versionen mit
        gleichem `hash`, sortiert `major_version desc, minor_version desc`).
        Eigenes, nicht-standardmaessiges Envelope OHNE `links`/`page`/
        `per_page`/`last_page`. Sowohl `creator` als auch `approver` sind
        geladen. Pro Version greift dieselbe Kategorie-ACL wie bei `show` —
        eine restriktive Kategorie kann einzelne (noch nicht freigegebene)
        Versionen ausblenden.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
      responses:
        '200':
          description: Versionsliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocumentResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      api_version:
                        type: string
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
                    type: document
                    attributes:
                      name: "Kalibrierschein_DMM-001"
                      hash: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
                      major_version: 1
                      minor_version: 1
                      version: "1.1"
                      is_latest: true
                      is_existed: true
                      filename: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.pdf"
                      filetype: pdf
                      type: check_cert
                      created: 1767434400
                      created_by: "45f09720-082f-5c74-9cfa-a605e2863904"
                      link_table: inventory
                      link_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      report_id: null
                      status: 1
                      action_title: null
                      released_first: 1
                    relationships:
                      creator:
                        data:
                          id: "45f09720-082f-5c74-9cfa-a605e2863904"
                          type: user
                          name: "Max Mustermann"
                      approver: []
                meta:
                  total: 1
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Wie bei `GET /documents/{id}` — nicht gefunden ODER durch Kategorie-ACL verdeckt.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                not_found:
                  summary: Dokument existiert nicht
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "Document not found."
                visibility_denied:
                  summary: Durch Kategorie-ACL verdeckt (IDOR-Schutz)
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "The requested resource was not found."

  /api/v2/documents/{id}/signatures:
    get:
      tags: [document]
      summary: Signaturen eines Dokuments auflisten
      description: |
        Erfordert Permission `dms_view` (V1-Parity: Ansehen ist Teil des
        Dokument-Sichtens, nur die Signieraktion selbst braucht
        `signature_write`). Keine Kategorie-ACL hier. Eigenes,
        nicht-standardmaessiges Envelope; jedes Element erweitert
        `SignatureAttributes` um `setting_name`/`user_name`. Sortiert
        `created_at desc`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
      responses:
        '200':
          description: Signaturliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        type:
                          type: string
                          enum: [signature]
                        attributes:
                          allOf:
                            - $ref: '#/components/schemas/SignatureAttributes'
                            - type: object
                              properties:
                                setting_name:
                                  type: string
                                  nullable: true
                                  description: Anzeigename der `SignatureSetting`
                                user_name:
                                  type: string
                                  nullable: true
                                  description: Voller Name des unterzeichnenden Benutzers
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66aa02"
                    type: signature
                    attributes:
                      setting_id: "3fa85f64-5717-4562-b3fc-2c963f66aa07"
                      user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                      value: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
                      object_id: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
                      type: dms
                      link_table: inventory
                      link_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      created_at: "2026-02-28T10:00:00.000000Z"
                      updated_at: "2026-02-28T10:00:00.000000Z"
                      setting_name: "Pruefer-Unterschrift"
                      user_name: "Max Mustermann"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/documents/check-name:
    post:
      tags: [document]
      summary: Dateinamen gegen ein Zuordnungsmuster pruefen
      description: |
        Erfordert Permission `dms_edit`. Prueft einen Dateinamen VOR dem
        eigentlichen Upload gegen das Namensmuster einer Dokumentart (V1:
        `AdminDocumentController::actionCheckFileName`). `DocumentNameCheckResource`
        hat bewusst KEIN `id`-Feld.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, file_setting_id]
              properties:
                filename:
                  type: string
                  maxLength: 255
                  example: "DMM-001check_cert.pdf"
                file_setting_id:
                  type: string
                  maxLength: 100
                  example: "3fa85f64-5717-4562-b3fc-2c963f66aa07"
      responses:
        '200':
          description: Pruefergebnis
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentNameCheckResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  type: document-name-check
                  attributes:
                    has_pattern: true
                    pattern: "{asset_number}{#ANY#}"
                    matches: true
                    link_table: inventory
                    link_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    expected_type: check_cert
                    reason: null
                meta:
                  api_version: "2.0.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Unbekannte `file_setting_id`
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "FileSetting not found."
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/documents/bulk-delete:
    post:
      tags: [document]
      summary: Mehrere Dokumente loeschen
      description: |
        Erfordert Permission `dms_delete`. **Ohne Sichtbarkeitsfilter** —
        anders als `bulk-download` wird hier NICHT gegen `DocumentVisibility`
        geprueft. Response enthaelt nur `deleted` (kein `skipped`/
        `skipped_ids`, dennoch schemakonform gegen `BulkDeleteResultSchema`,
        deren Zusatzfelder nullable sind).
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/documents/bulk-download:
    post:
      tags: [document]
      summary: Mehrere Dokumente als ZIP herunterladen
      description: |
        Erfordert Permission `document_download` — eine EIGENE Permission,
        getrennt von `dms_*`. Dokumente ohne (nach `DocumentVisibility`)
        sichtbare oder physisch vorhandene Datei werden stillschweigend
        uebersprungen; nur wenn ALLE Dateien fehlen, kommt 422. ZIP-Eintraege
        heissen `{name}_V{major}.{minor}.{filetype}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                    maxLength: 100
            example:
              ids: ["3fa85f64-5717-4562-b3fc-2c963f66aa01", "3fa85f64-5717-4562-b3fc-2c963f66aa08"]
      responses:
        '200':
          description: "ZIP-Archiv, Dateiname `documents_{unixtime}.zip`"
          content:
            application/zip:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Keine der angefragten Dateien ist physisch vorhanden (VERSCHACHTELT).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: No Files
                  detail: "Keine der ausgewählten Dateien ist physisch vorhanden."
        '500':
          description: |
            ZIP-Erstellung fehlgeschlagen (VERSCHACHTELT) — entweder konnte
            keine temporaere Datei angelegt werden, oder das ZIP-Archiv
            selbst liess sich nicht oeffnen.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                temp_file_failed:
                  summary: Temporaere Datei nicht anlegbar
                  value:
                    error:
                      status: 500
                      title: ZIP Failed
                      detail: "Temporäre Datei konnte nicht angelegt werden."
                zip_open_failed:
                  summary: ZIP-Archiv nicht erstellbar
                  value:
                    error:
                      status: 500
                      title: ZIP Failed
                      detail: "ZIP-Archiv konnte nicht erstellt werden."

  /api/v2/inventories/{mtag}/documents:
    get:
      tags: [document]
      summary: Dokumente eines Inventarobjekts auflisten
      description: |
        Erfordert Permission `inventory_view`. Neben regulaer verknuepften
        Dokumenten (`link_table=inventory`, `link_id={mtag}`) werden auch
        "shared pattern"-Dokumente eingeblendet: unverknuepfte Ordner-Import-
        Dokumente, deren generierter Musternamen zum Inventarobjekt passt
        (z.B. ein `{manufacturer}_{model}`-Foto erscheint bei allen
        baugleichen Geraeten). Ohne `include_related` kein 404 bei unbekanntem
        `mtag` — dann kommt einfach eine leere Liste. Nur `creator` geladen
        (kein `approver`). Sortiert nach `created`, neueste zuerst.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: include_related
          in: query
          description: |
            Abhaengige Dokumentenschiene: zusaetzlich die Dokumente der
            Kalibrierungen, Standortbewegungen, Reparaturen und Auftraege
            dieses Geraets. Jede Zeile traegt dann `origin_label` — die
            lesbare Herkunft ("Kalibrierung Z-4711, 04.06.2026"); ohne den
            Schalter bleibt das Feld `null`.

            Die Auftragsschiene haengt an `bookings_view`; ohne die Permission
            fehlt sie in der Antwort. **Achtung:** Mit diesem Schalter wird das
            Geraet aufgeloest und geprueft — ein unbekanntes oder fuer den
            Nutzer unsichtbares `mtag` liefert 404 statt einer leeren Liste.
          schema:
            type: boolean
          example: true
        - name: type
          in: query
          description: |
            Filter nach Dokumentart (`file_setting.column_name`). Steuert
            zugleich die Sichtbarkeit: passt Rolle/Gruppe des Nutzers nicht
            zur Kategorie, werden nur freigegebene Dokumente (`status=1`)
            geliefert.
          schema:
            type: string
        - name: latest_only
          in: query
          description: "Presence-Pruefung wie bei `GET /documents` — auch `latest_only=0` filtert auf `is_latest=1`."
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Dokumentliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: "Nur mit `include_related`: Geraet unbekannt oder fuer den Nutzer nicht sichtbar."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

    post:
      tags: [document]
      summary: Dokument fuer ein Inventarobjekt hochladen
      description: |
        Erfordert Permission `inventory_edit`. Wie `POST /documents`, aber
        `link_table`/`link_id` werden IMMER aus der URL abgeleitet
        (`inventory`/`{mtag}`) — im Body mitgeschickte `link_table`/`link_id`
        werden ignoriert. Passt die Zieldokumentart ein Namensmuster mit
        `{#ANY#}`-Platzhalter an, wird der Dateiname beim Umbenennen darin
        eingesetzt.
      parameters:
        - $ref: '#/components/parameters/mtag'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: "Max. 100 MB. Gefaehrliche Endungen werden abgelehnt."
                name:
                  type: string
                  maxLength: 255
                  description: "Standard: Dateiname ohne Endung"
                type:
                  type: string
                  maxLength: 255
                  description: Dokumentart (`file_setting.column_name`)
                  example: inventory_doc
      responses:
        '201':
          description: Dokument gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Wie `POST /documents` — flache Datei-Validierung ODER verschachtelter Upload-Pipeline-Fehler.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - $ref: '#/components/schemas/ProblemDetails'
              examples:
                upload_pipeline_failed:
                  summary: "Verschachtelt: Upload-Pipeline-Fehler"
                  value:
                    error:
                      status: 422
                      title: Upload Failed
                      detail: "Failed to store file in DMS."
                validation_error:
                  summary: "Flach: Datei-Validierung (RFC7807)"
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      file:
                        - "The file has a file type that is not allowed for security reasons."

  /api/v2/calibrations/{ctag}/documents:
    get:
      tags: [document]
      summary: Dokumente einer Kalibrierung auflisten
      description: |
        Erfordert Permission `calibration_view`. Wie `GET /inventories/{mtag}/documents`,
        inkl. "shared pattern"-Dokumente; kein 404 bei unbekanntem `ctag`.
      parameters:
        - name: ctag
          in: path
          required: true
          description: UUID der Kalibrierung (CTAG)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa04"
        - name: type
          in: query
          description: Filter nach Dokumentart; gated zugleich die Sichtbarkeit nicht-freigegebener Versionen (siehe Inventar-Variante).
          schema:
            type: string
        - name: latest_only
          in: query
          description: "Presence-Pruefung — auch `latest_only=0` filtert auf `is_latest=1`."
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Dokumentliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [document]
      summary: Dokument fuer eine Kalibrierung hochladen
      description: |
        Erfordert Permission `calibration_edit`. Wie `POST /inventories/{mtag}/documents`;
        `link_table`/`link_id` werden aus der URL abgeleitet
        (`calibration`/`{ctag}`).
      parameters:
        - name: ctag
          in: path
          required: true
          description: UUID der Kalibrierung (CTAG)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa04"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                  maxLength: 255
                type:
                  type: string
                  maxLength: 255
      responses:
        '201':
          description: Dokument gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Wie `POST /documents` — flache Datei-Validierung ODER verschachtelter Upload-Pipeline-Fehler.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - $ref: '#/components/schemas/ProblemDetails'
              examples:
                upload_pipeline_failed:
                  summary: "Verschachtelt: Upload-Pipeline-Fehler"
                  value:
                    error:
                      status: 422
                      title: Upload Failed
                      detail: "Failed to store file in DMS."
                validation_error:
                  summary: "Flach: Datei-Validierung (RFC7807)"
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      file:
                        - "The file has a file type that is not allowed for security reasons."

  /api/v2/bookings/{id}/documents:
    get:
      tags: [document]
      summary: Dokumente einer Buchung auflisten
      description: |
        Erfordert Permission `bookings_view`. Wie `GET /inventories/{mtag}/documents`,
        inkl. "shared pattern"-Dokumente; kein 404 bei unbekannter `id`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID der Buchung
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa06"
        - name: type
          in: query
          description: Filter nach Dokumentart; gated zugleich die Sichtbarkeit nicht-freigegebener Versionen.
          schema:
            type: string
        - name: latest_only
          in: query
          description: "Presence-Pruefung — auch `latest_only=0` filtert auf `is_latest=1`."
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Dokumentliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [document]
      summary: Dokument fuer eine Buchung hochladen
      description: |
        Erfordert Permission `bookings_edit`. Wie `POST /inventories/{mtag}/documents`;
        `link_table`/`link_id` werden aus der URL abgeleitet
        (`booking`/`{id}`).
      parameters:
        - name: id
          in: path
          required: true
          description: UUID der Buchung
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa06"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                  maxLength: 255
                type:
                  type: string
                  maxLength: 255
      responses:
        '201':
          description: Dokument gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Wie `POST /documents` — flache Datei-Validierung ODER verschachtelter Upload-Pipeline-Fehler.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - $ref: '#/components/schemas/ProblemDetails'
              examples:
                upload_pipeline_failed:
                  summary: "Verschachtelt: Upload-Pipeline-Fehler"
                  value:
                    error:
                      status: 422
                      title: Upload Failed
                      detail: "Failed to store file in DMS."
                validation_error:
                  summary: "Flach: Datei-Validierung (RFC7807)"
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      file:
                        - "The file has a file type that is not allowed for security reasons."

  /api/v2/locations/{ltag}/documents:
    get:
      tags: [document]
      summary: Dokumente einer Standortbewegung auflisten
      description: |
        Erfordert Permission `location_view`. Die fuenfte Dokumentenschiene des
        Geraets: unterschriebener Leihschein, Uebergabe- und
        Ruecknahmeprotokoll gehoeren an die Bewegung und nicht an das Geraet.
        Ansonsten wie `GET /inventories/{mtag}/documents`, inkl.
        "shared pattern"-Dokumente; kein 404 bei unbekanntem `ltag`.
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID der Standortbewegung (LTAG)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa06"
        - name: type
          in: query
          description: Filter nach Dokumentart; gated zugleich die Sichtbarkeit nicht-freigegebener Versionen.
          schema:
            type: string
        - name: latest_only
          in: query
          description: "Presence-Pruefung — auch `latest_only=0` filtert auf `is_latest=1`."
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Dokumentliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [document]
      summary: Dokument fuer eine Standortbewegung hochladen
      description: |
        Erfordert Permission `location_edit`. Wie
        `POST /inventories/{mtag}/documents`; `link_table`/`link_id` werden aus
        der URL abgeleitet (`location`/`{ltag}`).
      parameters:
        - name: ltag
          in: path
          required: true
          description: UUID der Standortbewegung (LTAG)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa06"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                  maxLength: 255
                type:
                  type: string
                  maxLength: 255
                  description: Dokumentart (`file_setting.column_name`) mit Bereich `location`
      responses:
        '201':
          description: Dokument gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Wie `POST /documents` — flache Datei-Validierung ODER verschachtelter Upload-Pipeline-Fehler.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - $ref: '#/components/schemas/ProblemDetails'

  /api/v2/repairs/{rtag}/documents:
    get:
      tags: [document]
      summary: Dokumente einer Reparatur auflisten
      description: |
        Erfordert Permission `repair_edit` — **NICHT** `repair_view` (bewusste
        Inkonsistenz gegenueber den drei anderen Entitaeten oben, GET und
        POST teilen sich hier dieselbe Permission). Ansonsten wie
        `GET /inventories/{mtag}/documents`, inkl. "shared pattern"-Dokumente;
        kein 404 bei unbekanntem `rtag`.
      parameters:
        - name: rtag
          in: path
          required: true
          description: UUID der Reparatur (RTAG)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa05"
        - name: type
          in: query
          description: Filter nach Dokumentart; gated zugleich die Sichtbarkeit nicht-freigegebener Versionen.
          schema:
            type: string
        - name: latest_only
          in: query
          description: "Presence-Pruefung — auch `latest_only=0` filtert auf `is_latest=1`."
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Dokumentliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [document]
      summary: Dokument fuer eine Reparatur hochladen
      description: |
        Erfordert Permission `repair_edit` (gleiche Permission wie GET auf
        diesem Pfad). Wie `POST /inventories/{mtag}/documents`;
        `link_table`/`link_id` werden aus der URL abgeleitet
        (`repair`/`{rtag}`).
      parameters:
        - name: rtag
          in: path
          required: true
          description: UUID der Reparatur (RTAG)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa05"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                  maxLength: 255
                type:
                  type: string
                  maxLength: 255
      responses:
        '201':
          description: Dokument gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Wie `POST /documents` — flache Datei-Validierung ODER verschachtelter Upload-Pipeline-Fehler.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - $ref: '#/components/schemas/ProblemDetails'
              examples:
                upload_pipeline_failed:
                  summary: "Verschachtelt: Upload-Pipeline-Fehler"
                  value:
                    error:
                      status: 422
                      title: Upload Failed
                      detail: "Failed to store file in DMS."
                validation_error:
                  summary: "Flach: Datei-Validierung (RFC7807)"
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      file:
                        - "The file has a file type that is not allowed for security reasons."

  /api/v2/documents/{id}/sign:
    post:
      tags: [document]
      summary: Dokument signieren
      description: |
        Erfordert Permission `signature_write`. UPSERT auf `(setting_id,
        document_id, type=dms)` — ein zweiter Aufruf mit derselben
        `setting_id` ersetzt die vorhandene Signatur statt eine zweite
        anzulegen. Antwortet mit 200 auch bei Erstanlage (nicht 201).
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature, setting_id]
              properties:
                signature:
                  type: string
                  description: Freies Signatur-Payload (z.B. Stroke-JSON oder Base64-Bild)
                  example: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
                setting_id:
                  type: string
                  maxLength: 36
                  example: "3fa85f64-5717-4562-b3fc-2c963f66aa07"
      responses:
        '200':
          description: Signatur gespeichert (Upsert)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      type:
                        type: string
                        enum: [signature]
                      attributes:
                        $ref: '#/components/schemas/SignatureAttributes'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
                      document_id:
                        type: string
                        format: uuid
              example:
                data:
                  id: "3fa85f64-5717-4562-b3fc-2c963f66aa02"
                  type: signature
                  attributes:
                    setting_id: "3fa85f64-5717-4562-b3fc-2c963f66aa07"
                    user_id: "45f09720-082f-5c74-9cfa-a605e2863904"
                    value: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
                    object_id: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
                    type: dms
                    link_table: inventory
                    link_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    created_at: "2026-08-03T10:00:00.000000Z"
                    updated_at: "2026-08-03T10:00:00.000000Z"
                meta:
                  message: "Document signed successfully."
                  document_id: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: "Entweder das Dokument oder die `SignatureSetting` existiert nicht."
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                document_not_found:
                  summary: Dokument existiert nicht
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "Document not found."
                setting_not_found:
                  summary: Signatur-Einstellung existiert nicht
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "SignatureSetting not found."
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/documents/{id}/signatures/{signatureId}:
    delete:
      tags: [document]
      summary: Signatur eines Dokuments entfernen
      description: Erfordert Permission `signature_write`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
        - name: signatureId
          in: path
          required: true
          description: UUID der Signatur
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa02"
      responses:
        '204':
          description: Erfolgreich entfernt
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Entweder das Dokument existiert nicht ODER die Signatur existiert
            nicht bzw. gehoert zu einem anderen Dokument (`signatureId`
            zusammen mit `object_uID`/`type=dms` nicht gefunden).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                document_not_found:
                  summary: Dokument existiert nicht
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "Document not found."
                signature_not_found:
                  summary: "Signatur fehlt oder gehoert zu anderem Dokument"
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "Signature not found."

  # ──────────────────────────────────────────────
  # NOTEPAD
  # ──────────────────────────────────────────────
  /api/v2/notepads/calendar:
    get:
      tags: [notepad]
      summary: Notepad-Eintraege eines Monats als Kalender-Events abrufen
      description: |
        Erfordert Permission `notepad_view`. NICHT-STANDARD FLACHES Format
        (kein JSON:API `type`/`attributes`-Wrapper) — fehlt zudem
        `contact_id`/`customer_id`, die die anderen Kalender-Shapes
        (`PUT .../calendar`, `PATCH .../date`, `POST /notepads/calendar`)
        mitliefern. Unterliegt derselben `NotepadVisibility` wie `GET /notepads`.
      parameters:
        - name: year
          in: query
          description: Standard aktuelles Jahr
          schema:
            type: integer
          example: 2026
        - name: month
          in: query
          description: Standard aktueller Monat
          schema:
            type: integer
            minimum: 1
            maximum: 12
          example: 2
      responses:
        '200':
          description: Kalender-Events des Monats
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        title:
                          type: string
                          nullable: true
                          description: entspricht `comment`
                        date:
                          type: string
                          nullable: true
                          description: entspricht `date_time` (roh, kein ISO8601)
                        status:
                          type: string
                          nullable: true
                        assigned_to:
                          type: string
                          nullable: true
                        inventory_id:
                          type: string
                          nullable: true
                  meta:
                    type: object
                    properties:
                      year:
                        type: integer
                      month:
                        type: integer
                      total:
                        type: integer
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
                    title: "Kalibrierung fällig"
                    date: "2026-02-28 10:00:00"
                    status: "new"
                    assigned_to: "45f09720-082f-5c74-9cfa-a605e2863904"
                    inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                meta:
                  year: 2026
                  month: 2
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [notepad]
      summary: Notepad-Eintraege fuer mehrere Inventarobjekte anlegen (Kalender-Mehrfachanlage)
      description: |
        Erfordert Permission `notepad_edit`. Legt PRO (deduplizierter)
        `inventory_id` einen eigenen Notepad-Eintrag an und uebernimmt dabei
        jeweils den Kunden des Inventarobjekts. Antwortet mit 201 und einer
        Liste von Kalender-Event-Objekten (nicht `NotepadResource`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [comment, inventory_ids, date_time]
              properties:
                comment:
                  type: string
                  maxLength: 2048
                inventory_ids:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    maxLength: 80
                contact_id:
                  type: string
                  nullable: true
                  maxLength: 36
                assigned_to:
                  type: string
                  nullable: true
                  maxLength: 36
                date_time:
                  type: string
                  example: "2026-03-05 09:00:00"
                status:
                  type: string
                  maxLength: 100
                  description: "Standard: `new`"
            example:
              comment: "Jaehrliche Kalibrierung"
              inventory_ids: ["ea79b775-8b4b-8e17-98c9-b33e7065ff6f", "b3e1c2d3-4567-8901-abcd-ef1234567890"]
              date_time: "2026-03-05 09:00:00"
      responses:
        '201':
          description: Notepad-Eintraege angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/NotepadCalendarEventResource'
                  meta:
                    type: object
                    properties:
                      created:
                        type: integer
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
                    type: calendar-event
                    attributes:
                      title: "Jaehrliche Kalibrierung"
                      date: "2026-03-05 09:00:00"
                      status: "new"
                      assigned_to: null
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      customer_id: "00000000-0000-4000-a000-000000000001"
                      contact_id: null
                meta:
                  created: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/notepads:
    get:
      tags: [notepad]
      summary: Notepad-Eintraege auflisten
      description: |
        Erfordert Permission `notepad_view`. Sichtbarkeit standardmaessig auf
        eigene/zugewiesene/Gruppen-/CC-Eintraege beschraenkt (`notepad_global`
        hebt das auf) — `scope=mine` erzwingt die Einschraenkung zusaetzlich,
        `scope=all` erweitert nur mit `notepad_global`. Immer sortiert
        `date_time DESC` — **kein `sort`-Parameter unterstuetzt.**
      parameters:
        - name: "filter[inventory_id]"
          in: query
          schema:
            type: string
        - name: "filter[customer_id]"
          in: query
          schema:
            type: string
        - name: "filter[status]"
          in: query
          description: Exakter Abgleich (kein `like:`)
          schema:
            type: string
        - name: "filter[assigned_to]"
          in: query
          schema:
            type: string
        - name: "filter[repair_id]"
          in: query
          schema:
            type: string
        - name: "filter[type]"
          in: query
          description: Geschaeftsrolle des Eintrags
          schema:
            type: string
            enum: [task, maintenance_reminder]
        - name: "filter[only_reminders]"
          in: query
          schema:
            type: boolean
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche NUR auf `comment`
          schema:
            type: string
        - name: scope
          in: query
          description: "`mine` (immer erlaubt) oder `all` (nur mit `notepad_global`); unbekannt/leer faellt auf den persoenlichen `enable_global`-Standard zurueck."
          schema:
            type: string
            enum: [mine, all]
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Notepad-Liste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotepadCollection'
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
                    type: notepad
                    attributes:
                      comment: "Kalibrierung fällig"
                      inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                      customer_id: "00000000-0000-4000-a000-000000000001"
                      contact_id: null
                      assigned_to: "45f09720-082f-5c74-9cfa-a605e2863904"
                      assigned_to_group: null
                      repair_id: null
                      entry_type: task
                      is_maintenance_reminder: false
                      date_time: "2026-02-28 10:00:00"
                      status: "new"
                      send_email: 0
                      reminder_at: null
                      created_by: "45f09720-082f-5c74-9cfa-a605e2863904"
                      modified_by: null
                      modified_at: null
                      inventory_id_label: "DMM-001"
                      customer_id_label: "Musterkunde GmbH"
                      contact_id_label: null
                      assigned_to_label: "Max Mustermann"
                      created_by_label: "Max Mustermann"
                      modified_by_label: null
                      repair_id_label: null
                      status_label: "Neu"
                    relationships:
                      assigned_group: []
                      additional_recipients: []
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0.0"
                links:
                  self: "/api/v2/notepads?page[number]=1&page[size]=25"
                  first: "/api/v2/notepads?page[number]=1&page[size]=25"
                  last: "/api/v2/notepads?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [notepad]
      summary: Notepad-Eintrag anlegen
      description: |
        Erfordert Permission `notepad_edit`. `status` defaultet auf `new`,
        `date_time` auf jetzt. **`send_email` defaultet auf `false`** —
        bewusste V2-Abweichung von V1 (dort Standard `true`), damit
        bestehende API-Clients nicht ungewollt E-Mails ausloesen.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotepadInput'
            example:
              comment: "Kalibrierung fällig"
              inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
              customer_id: "00000000-0000-4000-a000-000000000001"
              assigned_to: "45f09720-082f-5c74-9cfa-a605e2863904"
              date_time: "2026-02-28 10:00:00"
      responses:
        '201':
          description: "Notepad-Eintrag erstellt (Relationships geladen)"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/NotepadResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/notepads/{id}:
    get:
      tags: [notepad]
      summary: Einzelnen Notepad-Eintrag abrufen
      description: |
        Erfordert Permission `notepad_view`. Ausserhalb des sichtbaren
        Bereichs liegende IDs liefern denselben flachen 404 wie eine
        tatsaechlich fehlende ID (IDOR-Schutz, per Query-Scope statt
        nachtraeglichem ACL-Check). Relationships geladen.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      responses:
        '200':
          description: Notepad-Eintrag
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/NotepadResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [notepad]
      summary: Notepad-Eintrag aktualisieren
      description: |
        Partial Update, alle Felder optional. Erfordert Permission
        `notepad_edit`. **Quirk:** ein explizites `null` loescht das Feld —
        bei `inventory_id`/`customer_id` auf V1s `''`-Konvention (Response
        zeigt danach `""`, NICHT `null`!), bei `contact_id`/`assigned_to`/
        `repair_id`/`date_time`/`reminder_at`/`status` auf echtes `null`.
        Fehlt ein Feld im Body komplett, bleibt der Wert unveraendert. Ist
        `send_email=1` gesetzt UND haben sich getrackte Felder geaendert,
        wird eine Update-Benachrichtigung verschickt (Seiteneffekt).
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotepadUpdateInput'
            example:
              status: "test-status-notepad-done"
              contact_id: null
      responses:
        '200':
          description: "Notepad-Eintrag aktualisiert (Relationships geladen)"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/NotepadResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [notepad]
      summary: Notepad-Eintrag loeschen
      description: Erfordert Permission `notepad_delete`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/notepads/{id}/calendar:
    put:
      tags: [notepad]
      summary: Notepad-Eintrag ueber die Kalenderansicht aktualisieren
      description: |
        Partial Update, alle Felder optional. Erfordert Permission
        `notepad_edit`. Eigene Response-Huelle (`NotepadCalendarEventResource`)
        statt eines vollen `NotepadResource`. `inventory_ids` wird zwar
        akzeptiert/validiert, aber vom Controller nicht verwendet (totes
        Feld). Loest **keine** Update-Benachrichtigung aus, unabhaengig von
        `send_email` (anders als `PUT /notepads/{id}`).
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                comment:
                  type: string
                  maxLength: 2048
                contact_id:
                  type: string
                  nullable: true
                  maxLength: 36
                assigned_to:
                  type: string
                  nullable: true
                  maxLength: 36
                date_time:
                  type: string
                  nullable: true
                status:
                  type: string
                  maxLength: 100
                inventory_ids:
                  type: array
                  items:
                    type: string
                    maxLength: 80
                  description: Wird validiert, aber ignoriert (totes Feld)
            example:
              date_time: "2026-03-01 09:00:00"
      responses:
        '200':
          description: Kalender-Event
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/NotepadCalendarEventResource'
              example:
                data:
                  id: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
                  type: calendar-event
                  attributes:
                    title: "Kalibrierung fällig"
                    date: "2026-03-01 09:00:00"
                    status: "new"
                    assigned_to: "45f09720-082f-5c74-9cfa-a605e2863904"
                    inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    customer_id: "00000000-0000-4000-a000-000000000001"
                    contact_id: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/notepads/{id}/date:
    patch:
      tags: [notepad]
      summary: Datum eines Notepad-Eintrags schnell aendern
      description: |
        Fuer Drag-and-Drop im Kalender gedacht. Erfordert Permission
        `notepad_edit`. Loest bewusst **keine** Update-Benachrichtigung aus.
        Gleiche Response-Huelle wie `PUT /notepads/{id}/calendar`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [date_time]
              properties:
                date_time:
                  type: string
                  example: "2026-03-02 09:00:00"
      responses:
        '200':
          description: Kalender-Event
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/NotepadCalendarEventResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/notepads/{id}/status:
    post:
      tags: [notepad]
      summary: Status eines Notepad-Eintrags wechseln
      description: |
        Erfordert Permission `notepad_edit`. Wendet `StatusTransitionService`
        an (Default-Werte, geleerte Felder, Folgeaktionen). Relationships
        sind in der Erfolgsantwort IMMER leer (`[]`), da nicht nachgeladen.
        Loest eine Update-Benachrichtigung aus, wenn `send_email=1` UND sich
        der Status tatsaechlich aendert.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  maxLength: 36
                  example: "test-status-notepad-done"
      responses:
        '200':
          description: Status gewechselt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/NotepadResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        properties:
                          success:
                            type: boolean
                            enum: [true]
                          applied_defaults:
                            type: object
                            additionalProperties: true
                          cleared_fields:
                            type: array
                            items:
                              type: string
                          counter_updated:
                            type: string
                            nullable: true
                          reload_required:
                            type: boolean
                          follow_up_actions:
                            type: array
                            items:
                              type: string
              example:
                data:
                  id: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
                  type: notepad
                  attributes:
                    comment: "Kalibrierung fällig"
                    inventory_id: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
                    customer_id: "00000000-0000-4000-a000-000000000001"
                    contact_id: null
                    assigned_to: "45f09720-082f-5c74-9cfa-a605e2863904"
                    assigned_to_group: null
                    repair_id: null
                    entry_type: task
                    is_maintenance_reminder: false
                    date_time: "2026-02-28 10:00:00"
                    status: "test-status-notepad-done"
                    send_email: 0
                    reminder_at: null
                    created_by: "45f09720-082f-5c74-9cfa-a605e2863904"
                    modified_by: null
                    modified_at: 1769958000
                    inventory_id_label: "DMM-001"
                    customer_id_label: "Musterkunde GmbH"
                    contact_id_label: null
                    assigned_to_label: "Max Mustermann"
                    created_by_label: "Max Mustermann"
                    modified_by_label: null
                    repair_id_label: null
                    status_label: "Erledigt"
                  relationships:
                    assigned_group: []
                    additional_recipients: []
                meta:
                  transition:
                    success: true
                    applied_defaults: {}
                    cleared_fields: []
                    counter_updated: null
                    reload_required: false
                    follow_up_actions: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Zwei Faelle unter demselben Status-Code: (1) Pflichtfelder fuer
            den Zielstatus fehlen — EIGENE `data`-Huelle (kein `error`-Objekt,
            trotzdem HTTP 422); das Frontend zeigt daraufhin ein Modal fuer
            `POST .../additional-fields`. (2) unbekannte Status-UUID — flach/
            RFC7807.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          success:
                            type: boolean
                            enum: [false]
                          required_fields_missing:
                            type: boolean
                            enum: [true]
                          required_fields:
                            type: array
                            items:
                              type: string
                      meta:
                        type: object
                        properties:
                          api_version:
                            type: string
                  - $ref: '#/components/schemas/ProblemDetails'
              examples:
                required_fields_missing:
                  summary: Pflichtfelder fehlen
                  value:
                    data:
                      success: false
                      required_fields_missing: true
                      required_fields: ["field_a", "field_b"]
                    meta:
                      api_version: "2.0.0"
                unknown_status:
                  summary: Unbekannte Status-UUID
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: 'Status "bogus-status-id" not found for type "notepad"'
                    errors:
                      status:
                        - 'Status "bogus-status-id" not found for type "notepad"'

  /api/v2/notepads/{id}/additional-fields:
    post:
      tags: [notepad]
      summary: Pflichtfelder eines Statuswechsels nachreichen
      description: |
        Erfordert Permission `notepad_edit`. Schliesst einen zuvor mit 422
        `required_fields_missing` abgebrochenen Statuswechsel ab. Gleiches
        Erfolgs-/Fehlerformat wie `POST /notepads/{id}/status`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, fields]
              properties:
                status:
                  type: string
                  maxLength: 36
                  example: "test-status-notepad-done"
                fields:
                  type: object
                  description: Beliebige Schluessel/Wert-Paare der nachgereichten Pflichtfelder (jeder Werttyp, auch `null`)
                  additionalProperties: {}
            example:
              status: "test-status-notepad-done"
              fields:
                field_a: "Wert A"
                field_b: null
      responses:
        '200':
          description: Status gewechselt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/NotepadResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        properties:
                          success:
                            type: boolean
                            enum: [true]
                          applied_defaults:
                            type: object
                            additionalProperties: true
                          cleared_fields:
                            type: array
                            items:
                              type: string
                          counter_updated:
                            type: string
                            nullable: true
                          reload_required:
                            type: boolean
                          follow_up_actions:
                            type: array
                            items:
                              type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Zwei Faelle: (1) weiterhin fehlende Pflichtfelder — eigene
            `data`-Huelle. (2) unbekannte Status-UUID — flach/RFC7807.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          success:
                            type: boolean
                            enum: [false]
                          required_fields_missing:
                            type: boolean
                            enum: [true]
                          required_fields:
                            type: array
                            items:
                              type: string
                      meta:
                        type: object
                        properties:
                          api_version:
                            type: string
                  - $ref: '#/components/schemas/ProblemDetails'
              examples:
                required_fields_missing:
                  summary: Weiterhin Pflichtfelder offen
                  value:
                    data:
                      success: false
                      required_fields_missing: true
                      required_fields: ["field_b"]
                    meta:
                      api_version: "2.0.0"
                unknown_status:
                  summary: Unbekannte Status-UUID
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: 'Status "bogus-status-id" not found for type "notepad"'
                    errors:
                      status:
                        - 'Status "bogus-status-id" not found for type "notepad"'

  /api/v2/notepads/{id}/recipients:
    put:
      tags: [notepad]
      summary: Zusaetzliche Empfaenger eines Notepad-Eintrags ersetzen
      description: |
        Erfordert Permission `notepad_edit`. Voller Ersatz via `sync()` (kein
        Merge). `user_ids` muss als Schluessel vorhanden sein (`present`),
        darf aber ein leeres Array sein, um alle zusaetzlichen Empfaenger zu
        entfernen.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_ids]
              properties:
                user_ids:
                  type: array
                  items:
                    type: string
                    maxLength: 36
            example:
              user_ids: ["45f09720-082f-5c74-9cfa-a605e2863904"]
      responses:
        '200':
          description: "Empfaenger aktualisiert (Relationships neu geladen)"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/NotepadResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/notepads/bulk-delete:
    post:
      tags: [notepad]
      summary: Mehrere Notepad-Eintraege loeschen
      description: |
        Erfordert Permission `notepad_delete`. Mengenbasiertes `DELETE`
        (ohne Model-Events) innerhalb der `NotepadVisibility`-Sichtbarkeit.
        **Quirk:** `deleted` = `count($ids)` aus dem REQUEST, nicht die Zahl
        tatsaechlich betroffener Zeilen — ausserhalb des sichtbaren Bereichs
        liegende IDs werden von der Query stillschweigend uebersprungen, aber
        trotzdem mitgezaehlt. `skipped`/`skipped_ids` sind immer `0`/`[]`.
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/customers/{ktag}/notepads:
    get:
      tags: [notepad]
      summary: Notepad-Eintraege eines Kunden auflisten
      description: |
        Erfordert Permission `customers_view` — **NICHT** `notepad_view`.
        Prueft zuerst, ob der Kunde selbst sichtbar ist (sonst 404 `"Customer
        not found."`), skaliert danach zusaetzlich per `NotepadVisibility`.
      parameters:
        - name: ktag
          in: path
          required: true
          description: KTAG (UUID) des Kunden
          schema:
            type: string
            format: uuid
          example: "45f09720-082f-5c74-9cfa-a605e2863904"
        - name: "filter[type]"
          in: query
          schema:
            type: string
            enum: [task, maintenance_reminder]
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Notepad-Liste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotepadCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: "Kunde nicht gefunden oder nicht sichtbar"
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "Customer not found."

  /api/v2/inventories/{mtag}/notepads:
    get:
      tags: [notepad]
      summary: Notepad-Eintraege eines Inventarobjekts auflisten
      description: |
        Erfordert Permission `inventory_view`. Kein 404 bei unbekanntem
        `mtag` (das Inventarobjekt wird nicht per `findOrFail` geprueft) —
        liefert einfach eine leere Liste.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - name: "filter[type]"
          in: query
          schema:
            type: string
            enum: [task, maintenance_reminder]
        - name: "filter[only_reminders]"
          in: query
          schema:
            type: boolean
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Notepad-Liste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotepadCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # NOTEPAD ATTACHMENTS
  # ──────────────────────────────────────────────
  /api/v2/notepads/{notepadId}/attachments:
    get:
      tags: [notepad]
      summary: Anhaenge eines Notepad-Eintrags auflisten
      description: |
        Erfordert Permission `notepad_view`. Prueft zuerst die
        Notepad-Sichtbarkeit (404 `"Notepad not found."`, bevor ueberhaupt
        nach Anhaengen gesucht wird). Anhaenge sind normale DMS-Dokumente
        (`link_table=notepad`, `link_id={notepadId}`, `type=notepad_doc`),
        response ist `DocumentCollection`. Sortiert `created desc`, `creator`
        eager-geladen.
      parameters:
        - name: notepadId
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Anhangliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
                    type: document
                    attributes:
                      name: "Foto_Geraeteschaden"
                      hash: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
                      major_version: 1
                      minor_version: 0
                      version: "1.0"
                      is_latest: true
                      is_existed: true
                      filename: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.jpg"
                      filetype: jpg
                      type: notepad_doc
                      created: 1767434400
                      created_by: "45f09720-082f-5c74-9cfa-a605e2863904"
                      link_table: notepad
                      link_id: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
                      report_id: null
                      status: 0
                      action_title: null
                      released_first: null
                    relationships:
                      creator:
                        data:
                          id: "45f09720-082f-5c74-9cfa-a605e2863904"
                          type: user
                          name: "Max Mustermann"
                      approver: []
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
                  api_version: "2.0.0"
                links:
                  self: "/api/v2/notepads/3fa85f64-5717-4562-b3fc-2c963f66aa03/attachments?page[number]=1&page[size]=25"
                  first: "/api/v2/notepads/3fa85f64-5717-4562-b3fc-2c963f66aa03/attachments?page[number]=1&page[size]=25"
                  last: "/api/v2/notepads/3fa85f64-5717-4562-b3fc-2c963f66aa03/attachments?page[number]=1&page[size]=25"
                  next: null
                  prev: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [notepad]
      summary: Anhang zu einem Notepad-Eintrag hochladen
      description: |
        Erfordert Permission `notepad_edit`. Prueft zuerst die
        Notepad-Sichtbarkeit. **Max. 50 MB (51200 KB)** — kleiner als das
        100-MB-Limit von `POST /documents`. `type` ist fest `notepad_doc`,
        `link_table`/`link_id` ergeben sich aus der URL. Gleicher
        Endungs-Blocklist-Schutz wie der zentrale DMS-Upload.
      parameters:
        - name: notepadId
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: "Max. 50 MB (51200 KB). Gefaehrliche Endungen werden abgelehnt."
      responses:
        '201':
          description: Anhang gespeichert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Zwei Fehlerformen: flache Datei-Validierung (RFC7807) ODER
            VERSCHACHTELTER Upload-Fehler (`title: "Upload Failed"`, u.a.
            `"Failed to store file in DMS."`, `"Failed to store file version
            in DMS."`, `"Failed to upload attachment."`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ErrorResponse'
                  - $ref: '#/components/schemas/ProblemDetails'
              examples:
                upload_failed:
                  summary: "Verschachtelt: Upload-Fehler"
                  value:
                    error:
                      status: 422
                      title: Upload Failed
                      detail: "Failed to store file in DMS."
                validation_error:
                  summary: "Flach: Datei-Validierung (RFC7807)"
                  value:
                    type: "https://httpstatuses.com/422"
                    title: Validation Error
                    status: 422
                    detail: "The given data was invalid."
                    errors:
                      file:
                        - "The file has a file type that is not allowed for security reasons."

  /api/v2/notepads/{notepadId}/attachments/{documentId}/download:
    get:
      tags: [notepad]
      summary: Anhang eines Notepad-Eintrags herunterladen
      description: |
        Erfordert Permission `notepad_view`. Prueft zuerst die
        Notepad-Sichtbarkeit, dann ob `documentId` tatsaechlich zu diesem
        `notepadId` gehoert.
      parameters:
        - name: notepadId
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
        - name: documentId
          in: path
          required: true
          description: UUID des Anhang-Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
      responses:
        '200':
          description: "Dateiinhalt (`application/octet-stream`, `Content-Disposition: attachment`)"
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Drei Ursachen, zwei Formen: Notepad nicht gefunden/sichtbar
            (flach, `"Notepad not found."`) oder `documentId` gehoert nicht
            zu diesem Notepad (flach, `"Document not found."`) — ODER die
            Zeile existiert, aber die Datei fehlt physisch auf der Platte
            (VERSCHACHTELT).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ProblemDetails'
                  - $ref: '#/components/schemas/ErrorResponse'
              examples:
                notepad_not_found:
                  summary: "Flach: Notepad nicht gefunden/sichtbar"
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "Notepad not found."
                document_not_found:
                  summary: "Flach: Dokument gehoert nicht zu diesem Notepad"
                  value:
                    type: "https://httpstatuses.com/404"
                    title: Not Found
                    status: 404
                    detail: "Document not found."
                file_missing_on_disk:
                  summary: "Verschachtelt: Datei fehlt physisch"
                  value:
                    error:
                      status: 404
                      title: Not Found
                      detail: "The attachment file could not be found on disk."

  /api/v2/notepads/{notepadId}/attachments/{documentId}:
    delete:
      tags: [notepad]
      summary: Anhang eines Notepad-Eintrags loeschen
      description: |
        Erfordert Permission `notepad_delete`. Prueft zuerst die
        Notepad-Sichtbarkeit, dann ob `documentId` zu diesem `notepadId`
        gehoert — gehoert er zu einem ANDEREN Notepad, ist das Ergebnis 404
        `"Document not found."`, NICHT 403. Loescht die physische Datei nur,
        wenn keine andere Dokument-Zeile denselben `hash` teilt.
      parameters:
        - name: notepadId
          in: path
          required: true
          description: UUID des Notepad-Eintrags
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa03"
        - name: documentId
          in: path
          required: true
          description: UUID des Anhang-Dokuments (dms uID)
          schema:
            type: string
            format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66aa01"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # REPAIRS
  # ──────────────────────────────────────────────
  /api/v2/repairs:
    get:
      tags: [repair]
      summary: Reparaturen auflisten
      description: |
        Paginierte, generisch filterbare Liste. Erfordert Permission `repair_view`.
        Sichtbar sind nur Reparaturen, deren `inventory_id` zu einem fuer den
        Benutzer sichtbaren Inventarobjekt gehoert (`RepairVisibility`-Scope).
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: "filter[status_code]"
          in: query
          description: "Generische Filterung ueber FilterService (exakt, `like:`, `gt:`, `in:`, `between:`, ...)"
          schema:
            type: string
          example: "A"
        - name: "filter[inventory_id]"
          in: query
          schema:
            type: string
            format: uuid
        - name: "filter[scope]"
          in: query
          description: "`mine` — nur vom aktuellen Benutzer angelegte Reparaturen"
          schema:
            type: string
            enum: [mine]
        - name: sort
          in: query
          description: "Feldname, Praefix `-` fuer absteigend. Beliebige konfigurierte Spalte."
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Reparaturliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RepairCollection'
              example:
                data:
                  - id: "8f14e45f-ceea-467e-bd76-15782b0d0088"
                    type: repair
                    attributes:
                      inventory_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                      description: "Kalibrierung fehlgeschlagen - Reparatur"
                      notes: "Displayfehler behoben"
                      repair_date: "2026-07-01 00:00:00"
                      repair_time: "08:00:00"
                      status_code: "A"
                      status: null
                      data_source: "cal"
                      custom_fields: {}
                      inventory_id_label: "DMM-001"
                      status_label: "Aktiv"
                      category_ids: []
                    relationships:
                      inventory:
                        data: null
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [repair]
      summary: Reparatur anlegen
      description: |
        Erfordert Permission `repair_edit`. `id` (UUID) wird automatisch generiert.
        `category_ids` weist die Reparatur Kategorien vom Typ `repair` zu.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RepairInput'
            example:
              inventory_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
              description: "Kalibrierung fehlgeschlagen - Reparatur"
              notes: "Displayfehler behoben"
              repair_date: "2026-07-01 00:00:00"
              status_code: "A"
      responses:
        '201':
          description: Reparatur erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/RepairResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/repairs/export:
    get:
      tags: [repair]
      summary: Reparaturen exportieren
      description: |
        Export als CSV, JSON, XLSX oder PDF. Erfordert Permission `repair_view`.
        Nutzt dieselbe volle FilterService-Filterung wie `GET /repairs`.
        Bei `format=json` weicht die Antwort vom JSON:API-Schema ab (siehe unten);
        `csv`/`xlsx`/`pdf` liefern einen binaeren Datei-Download.
      parameters:
        - name: format
          in: query
          schema:
            type: string
            enum: [csv, json, xlsx, pdf]
            default: csv
        - name: columns
          in: query
          description: "Kommaseparierte Spaltenliste. Standard: `status_code,notes,inventory_id`"
          schema:
            type: string
        - name: ids
          in: query
          description: Kommaseparierte Liste von Reparatur-UUIDs (schraenkt den Export ein)
          schema:
            type: string
        - name: with_field_names
          in: query
          schema:
            type: boolean
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: |
            Bei `format=json`: Export-Envelope (`columns`/`data`/`total`) statt
            JSON:API-Ressourcen. Sonst: binaerer Download (Dateiname
            `reparatur_export.<format>`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  columns:
                    type: object
                    additionalProperties:
                      type: string
                    description: "api_name zu Spaltenlabel"
                  data:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  total:
                    type: integer
              example:
                columns:
                  status_code: Status
                  notes: Notizen
                  inventory_id: Inventar-ID
                data:
                  - status_code: "A"
                    notes: "Displayfehler behoben"
                    inventory_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/repairs/{rtag}:
    get:
      tags: [repair]
      summary: Einzelne Reparatur abrufen
      description: |
        Erfordert Permission `repair_view`. `relationships.inventory` ist nur
        hier (nicht in der Listenansicht) befuellt. Liegt die zugehoerige
        `inventory_id` ausserhalb der sichtbaren Inventarobjekte des Benutzers,
        antwortet der Endpunkt mit 404 (nicht 403).
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: rtag
          in: path
          required: true
          description: UUID der Reparatur
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      responses:
        '200':
          description: Reparatur
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/RepairResource'
              example:
                data:
                  id: "8f14e45f-ceea-467e-bd76-15782b0d0088"
                  type: repair
                  attributes:
                    inventory_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    description: "Kalibrierung fehlgeschlagen - Reparatur"
                    notes: "Displayfehler behoben"
                    repair_date: "2026-07-01 00:00:00"
                    repair_time: "08:00:00"
                    status_code: "A"
                    status: null
                    data_source: "cal"
                    custom_fields: {}
                    inventory_id_label: "DMM-001"
                    status_label: "Aktiv"
                    category_ids: []
                  relationships:
                    inventory:
                      data:
                        id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                        type: inventory
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [repair]
      summary: Reparatur aktualisieren
      description: |
        Partial Update, `inventory_id` ist nicht aenderbar. Erfordert Permission
        `repair_edit`. Ist `status` im Body enthalten und weicht vom aktuellen
        Status ab, durchlaeuft die Aenderung denselben StatusTransitionService wie
        `POST .../status` — inkl. moeglicher 422-Antwort mit
        `data.required_fields_missing` (siehe dort), statt der Standard-422-Form.
      parameters:
        - name: rtag
          in: path
          required: true
          description: UUID der Reparatur
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RepairUpdateInput'
            example:
              notes: "Displayfehler behoben, Endkontrolle ausstehend"
              status_code: "B"
      responses:
        '200':
          description: |
            Reparatur aktualisiert. Bei Statuswechsel zusaetzlich
            `meta.transition` (siehe `POST .../status`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/RepairResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        nullable: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [repair]
      summary: Reparatur loeschen
      description: Erfordert Permission `repair_delete`. Loest `ResourceDeleted`-Event aus.
      parameters:
        - name: rtag
          in: path
          required: true
          description: UUID der Reparatur
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/repairs/{rtag}/audits:
    get:
      tags: [repair]
      summary: Aenderungsverlauf einer Reparatur abrufen
      description: |
        Erfordert Permission `repair_view`. Sortiert `timestamp` DESC.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
        - name: "filter[field_name]"
          in: query
          schema:
            type: string
        - name: "filter[type]"
          in: query
          description: "V1-Aktionstyp, z.B. `change_repair_field`"
          schema:
            type: string
        - name: "filter[from]"
          in: query
          description: Unix-Zeitstempel (inklusive)
          schema:
            type: integer
        - name: "filter[to]"
          in: query
          description: Unix-Zeitstempel (inklusive)
          schema:
            type: integer
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Audit-Eintraege der Reparatur
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/repairs/{rtag}/signatures:
    get:
      tags: [repair]
      summary: Signaturen einer Reparatur auflisten
      description: Erfordert Permission `repair_view`. Sortiert `created_at` DESC. Nicht paginiert.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      responses:
        '200':
          description: Signaturliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SignatureResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b"
                    type: signature
                    attributes:
                      setting_id: "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d"
                      user_id: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                      value: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
                      object_id: null
                      type: report
                      link_table: repair
                      link_id: "8f14e45f-ceea-467e-bd76-15782b0d0088"
                      created_at: "2026-07-01T09:00:00.000000Z"
                      updated_at: "2026-07-01T09:00:00.000000Z"
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/repairs/{rtag}/sign:
    post:
      tags: [repair]
      summary: Reparaturbericht signieren
      description: Erfordert Permission `repair_edit`. Speichert eine elektronische Signatur zum Reparaturbericht.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature, setting_id]
              properties:
                signature:
                  type: string
                  description: Base64-Daten-URI der Signatur
                  example: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
                setting_id:
                  type: string
                  maxLength: 36
                  description: UUID der Report-Einstellung
                report_id:
                  type: string
                  maxLength: 36
                  nullable: true
      responses:
        '200':
          description: Signiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SignatureResource'
                  meta:
                    type: object
                    properties:
                      message:
                        type: string
              example:
                data:
                  id: "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b"
                  type: signature
                  attributes:
                    setting_id: "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d"
                    user_id: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                    value: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
                    object_id: null
                    type: report
                    link_table: repair
                    link_id: "8f14e45f-ceea-467e-bd76-15782b0d0088"
                    created_at: "2026-07-01T09:00:00.000000Z"
                    updated_at: "2026-07-01T09:00:00.000000Z"
                meta:
                  message: "Repair report signed successfully."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/repairs/{rtag}/close:
    post:
      tags: [repair]
      summary: Reparatur abschliessen
      description: |
        Setzt `status_code` hart auf `C`, unabhaengig vom konfigurierten Workflow
        (umgeht den StatusTransitionService — kein 422-Risiko durch fehlende
        Pflichtfelder). Erfordert Permission `repair_edit`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  maxLength: 102
      responses:
        '200':
          description: Reparatur abgeschlossen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/RepairResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/repairs/{rtag}/status:
    post:
      tags: [repair]
      summary: Status per Workflow wechseln
      description: |
        Fuehrt den Statuswechsel ueber den StatusTransitionService aus (wendet
        Status-Defaults an, leert konfigurierte Felder, prueft Pflichtfelder).
        Erfordert Permission `repair_edit`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  maxLength: 36
                  description: Ziel-Status-UUID
      responses:
        '200':
          description: Statuswechsel erfolgreich
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/RepairResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        properties:
                          success:
                            type: boolean
                          applied_defaults:
                            type: object
                            additionalProperties: true
                          cleared_fields:
                            type: array
                            items:
                              type: string
                          counter_updated:
                            type: string
                            nullable: true
                          reload_required:
                            type: boolean
                          follow_up_actions:
                            type: array
                            items:
                              type: string
              example:
                data:
                  id: "8f14e45f-ceea-467e-bd76-15782b0d0088"
                  type: repair
                  attributes:
                    inventory_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    status_code: "C"
                meta:
                  transition:
                    success: true
                    applied_defaults:
                      status_code: "C"
                    cleared_fields: []
                    counter_updated: null
                    reload_required: false
                    follow_up_actions: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Entweder Standard-Validierungsfehler (RFC7807, siehe `ValidationError`)
            oder — spezifisch fuer diesen Endpunkt — blockierter Uebergang wegen
            fehlender Pflichtfelder (datenfoermig, kein `error`-Objekt).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success:
                        type: boolean
                        example: false
                      required_fields_missing:
                        type: boolean
                        example: true
                      required_fields:
                        type: array
                        items:
                          type: string
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  success: false
                  required_fields_missing: true
                  required_fields: ["notes"]
                meta:
                  api_version: "2.0"

  /api/v2/repairs/{rtag}/additional-fields:
    post:
      tags: [repair]
      summary: Statuswechsel mit Zusatzfeldern speichern
      description: |
        Wie `POST .../status`, uebergibt zusaetzlich Werte fuer die beim
        Zielstatus als Pflicht markierten Felder in einem Schritt.
        Erfordert Permission `repair_edit`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, fields]
              properties:
                status:
                  type: string
                  maxLength: 36
                  description: Ziel-Status-UUID
                fields:
                  type: object
                  additionalProperties: true
                  description: "api_name zu Wert (Wert darf `null` sein)"
      responses:
        '200':
          description: Uebernommen (RepairResource + `meta.transition`, siehe `POST .../status`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/RepairResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Wie bei `POST .../status`: entweder RFC7807-Validierungsfehler oder
            `data.required_fields_missing` mit `data.required_fields`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success:
                        type: boolean
                      required_fields_missing:
                        type: boolean
                      required_fields:
                        type: array
                        items:
                          type: string
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string

  /api/v2/repairs/bulk-delete:
    post:
      tags: [repair]
      summary: Mehrere Reparaturen loeschen
      description: |
        Erfordert Permission `repair_delete`. Prueft Sichtbarkeit vor dem
        Loeschen — `deleted` entspricht der tatsaechlichen Anzahl geloeschter
        Zeilen (im Gegensatz zu Booking/Price weiter unten, siehe dort).
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # REPAIR REPORTS
  # ──────────────────────────────────────────────
  /api/v2/repairs/{rtag}/reports:
    get:
      tags: [repair]
      summary: Verfuegbare Berichte einer Reparatur auflisten
      description: |
        Gefiltert nach Rollen-/Gruppensichtbarkeit (GRID_NAME `repair`).
        Erfordert Permission `repair_view`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      responses:
        '200':
          description: Berichtseinstellungen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportSettingResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d"
                    type: report-setting
                    attributes:
                      button_name: "Reparaturbericht"
                      menu: "Master"
                      format: pdf
                      folder_name: repairs
                      report_name: "repair_report.jrxml"
                      is_label: false
                      is_print: false
                      email_attachment: true
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/repairs/{rtag}/reports/{reportId}/parameters:
    get:
      tags: [repair]
      summary: Prompt-Parameter eines Berichts abrufen
      description: |
        Nur Parameter, die tatsaechlich abgefragt werden muessen (`RTAG`,
        `RepairId`, `InventoryId` werden automatisch befuellt und ausgeschlossen).
        Erfordert Permission `repair_view`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
        - name: reportId
          in: path
          required: true
          description: UUID der Berichtseinstellung
          schema:
            type: string
            format: uuid
          example: "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d"
      responses:
        '200':
          description: Berichtsparameter
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportParameterResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: Start_Date
                    type: report-parameter
                    attributes:
                      name: Start_Date
                      type: Date
                      default: null
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            `ReportSetting` nicht gefunden oder fuer die Rolle/Gruppe nicht
            sichtbar (im zweiten Fall generisches `detail`
            "The requested resource was not found." statt "ReportSetting not found.").
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "ReportSetting not found."

  /api/v2/repairs/{rtag}/reports/{reportId}/preview:
    get:
      tags: [repair]
      summary: Bericht als PDF-Vorschau rendern
      description: |
        Liefert immer PDF, unabhaengig vom konfigurierten Format
        (`Content-Disposition: inline`). Bereits erfasste Signaturen
        (`POST .../sign`) sind eingestempelt. Erfordert Permission `repair_view`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
        - name: reportId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d"
        - name: "parameters[Name]"
          in: query
          description: |
            Dynamische Report-Parameter, ein Query-Key je Parametername
            (siehe `GET .../reports/{reportId}/parameters`).
          schema:
            type: string
          example: "2026-01-01"
      responses:
        '200':
          description: PDF-Datei (inline)
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          description: Berichtsgenerierung fehlgeschlagen (Report-Engine-Fehler)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."

  /api/v2/repairs/{rtag}/reports/generate:
    post:
      tags: [repair]
      summary: Bericht generieren und herunterladen
      description: |
        Binaerer Download (`Content-Disposition: attachment`,
        `Cache-Control: no-cache, no-store, must-revalidate`). Ist am
        `ReportSetting` `save_to_dms=1` konfiguriert, wird die Datei zusaetzlich
        automatisch im DMS abgelegt (Fehler dabei bleiben unbemerkt/still).
        Erfordert Permission `repair_edit`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d"
              format: pdf
      responses:
        '200':
          description: Binaerer Datei-Download
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."

  /api/v2/repairs/{rtag}/reports/save-to-dms:
    post:
      tags: [repair]
      summary: Bericht generieren und im DMS ablegen
      description: Erfordert Permission `repair_edit`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                file_setting_id:
                  type: string
                parameters:
                  type: object
                  additionalProperties:
                    type: string
      responses:
        '201':
          description: Im DMS abgelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
              example:
                data:
                  id: "d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a"
                  type: document
                  attributes:
                    name: "repair_report"
                    filename: "repair_report.pdf"
                    filetype: pdf
                    type: report
                    link_table: repair
                    link_id: "8f14e45f-ceea-467e-bd76-15782b0d0088"
                    status: 0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: DMS-Ablage fehlgeschlagen (z.B. keine passende Dateieinstellung gefunden)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "DMS-Ablage fehlgeschlagen"
                  detail: "Es konnte keine passende Dateieinstellung ermittelt werden."
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."

  /api/v2/repairs/{rtag}/reports/release:
    post:
      tags: [repair]
      summary: Bericht generieren und freigeben
      description: |
        Legt den Bericht ueber alle fuer die Rolle/Gruppe des Benutzers
        konfigurierten Freigabe-Regeln (`document_release`) im DMS ab.
        Erfordert Permission `repair_edit`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                file_setting_id:
                  type: string
                parameters:
                  type: object
                  additionalProperties:
                    type: string
      responses:
        '201':
          description: Freigegeben — ein `DocumentResource` je zutreffender Freigabe-Regel
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocumentResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            DMS-Ablage fehlgeschlagen (manchmal mit zusaetzlichem `candidates`-Array)
            oder keine Freigabe-Regel fuer die Rolle/Gruppe konfiguriert.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                keine_freigabe:
                  summary: Keine Freigabe-Regel konfiguriert
                  value:
                    error:
                      status: 422
                      title: "Keine Freigabe konfiguriert"
                      detail: "Für diesen Bericht ist keine Freigabe-Regel für Ihre Rolle/Gruppe konfiguriert."
                dms_fehlgeschlagen:
                  summary: DMS-Ablage fehlgeschlagen
                  value:
                    error:
                      status: 422
                      title: "DMS-Ablage fehlgeschlagen"
                      detail: "Es konnte keine passende Dateieinstellung ermittelt werden."
                      candidates: ["certificate", "inventory_doc"]
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."

  /api/v2/repairs/{rtag}/reports/send:
    post:
      tags: [repair]
      summary: Bericht generieren und per E-Mail versenden
      description: Erfordert Permission `repair_edit`.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "8f14e45f-ceea-467e-bd76-15782b0d0088"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id, to_address, subject, body]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                to_address:
                  type: string
                  format: email
                  maxLength: 255
                subject:
                  type: string
                  maxLength: 255
                body:
                  type: string
                cc_address:
                  type: string
                  nullable: true
                  maxLength: 255
                bcc_address:
                  type: string
                  nullable: true
                  maxLength: 255
                parameters:
                  type: object
                  additionalProperties:
                    type: string
            example:
              report_id: "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d"
              to_address: "kunde@example.com"
              subject: "Ihr Reparaturbericht"
              body: "Anbei Ihr Reparaturbericht."
      responses:
        '201':
          description: Versendet
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/MailerMessageResource'
              example:
                data:
                  id: "42"
                  type: mailer-message
                  attributes:
                    status: sent
                    error_message: null
                    to_address: "kunde@example.com"
                    subject: "Ihr Reparaturbericht"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."
        '503':
          description: Mailer-Konfiguration fehlt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 503
                  title: "Mailer Configuration Missing"
                  detail: "No mailer configuration found for this tenant."

  # ──────────────────────────────────────────────
  # BOOKINGS
  # ──────────────────────────────────────────────
  /api/v2/bookings:
    get:
      tags: [booking]
      summary: Auftraege auflisten
      description: |
        Erfordert Permission `bookings_view`. Anders als die meisten anderen
        Listen **keine** generische FilterService-Filterung, sondern nur die drei
        unten dokumentierten Filter; Sortierung ist fest auf `modified` DESC
        (kein `?sort=`).
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: "filter[customer_id]"
          in: query
          description: Exakte Kunden-UUID
          schema:
            type: string
            format: uuid
        - name: "filter[status]"
          in: query
          description: Exakter Status
          schema:
            type: string
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche (LIKE) auf `number`/`comment`
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Auftragsliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingCollection'
              example:
                data:
                  - id: "00000000-0000-4000-a000-000000000b01"
                    type: booking
                    attributes:
                      customer_id: "00000000-0000-4000-a000-000000000001"
                      number: "ORD-2026-001"
                      date: "2026-02-28"
                      status: offer
                      comment: "Test booking"
                      delivery_condition: null
                      delivery_costs: 0
                      customer_contact_id: ""
                      delivery_contact_id: ""
                      invoice_contact_id: ""
                      delivery_customer_id: null
                      invoice_customer_id: null
                      modified: 1772486400
                      modified_by: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                      custom_fields: {}
                      customer_id_label: "Test GmbH"
                      status_label: "Angebot"
                      category_ids: []
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [booking]
      summary: Auftrag anlegen
      description: |
        Erfordert Permission `bookings_edit`. `number` ist nur eindeutig
        pflichtig, wenn dies per Property-Einstellung aktiviert ist.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookingInput'
            example:
              customer_id: "00000000-0000-4000-a000-000000000001"
              number: "ORD-2026-003"
              date: "2026-03-01"
              status: offer
              delivery_condition: "DAP Werk"
              delivery_costs: 15.00
      responses:
        '201':
          description: Auftrag erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            RFC7807-Validierungsfehler. Ohne `number` und ohne konfigurierte
            Nummernvorgabe z.B. `errors.number[0]`: "Es ist keine
            Auftragsnummer angegeben und für das Feld ist keine Vorgabe hinterlegt."
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  number:
                    - "Es ist keine Auftragsnummer angegeben und für das Feld ist keine Vorgabe hinterlegt."

  /api/v2/bookings/export:
    get:
      tags: [booking]
      summary: Auftraege exportieren
      description: |
        Export als CSV, JSON, XLSX oder PDF. Erfordert Permission `bookings_view`.
        Nutzt (anders als `GET /bookings`) die volle FilterService-Filterung
        inkl. konfigurierbarer Sortierung.
      parameters:
        - name: format
          in: query
          schema:
            type: string
            enum: [csv, json, xlsx, pdf]
            default: csv
        - name: columns
          in: query
          description: "Kommaseparierte Spaltenliste. Standard: `number,date,status,comment`"
          schema:
            type: string
        - name: ids
          in: query
          schema:
            type: string
        - name: with_field_names
          in: query
          schema:
            type: boolean
        - name: sort
          in: query
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: |
            Bei `format=json`: Export-Envelope (`columns`/`data`/`total`).
            Sonst binaerer Download (Dateiname `buchungen_export.<format>`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  columns:
                    type: object
                    additionalProperties:
                      type: string
                  data:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  total:
                    type: integer
              example:
                columns:
                  number: Buchungsnummer
                  date: Datum
                  status: Status
                  comment: Kommentar
                data:
                  - number: "ORD-2026-001"
                    date: "2026-02-28"
                    status: offer
                    comment: "Test booking"
                total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/bookings/{id}:
    get:
      tags: [booking]
      summary: Einzelnen Auftrag abrufen
      description: Erfordert Permission `bookings_view`. Keine `relationships` im Response.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: id
          in: path
          required: true
          description: UUID des Auftrags
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Auftrag
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [booking]
      summary: Auftrag aktualisieren
      description: |
        Partial Update ohne dediziertes FormRequest. Anders als bei Repair
        durchlaeuft ein geaenderter `status` **keinen** StatusTransitionService/
        BookingTransitionPolicy — es ist ein reiner Spaltenschreibvorgang.
        Erfordert Permission `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookingInput'
            example:
              comment: "Kunde wuenscht Express-Bearbeitung"
      responses:
        '200':
          description: Auftrag aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [booking]
      summary: Auftrag loeschen
      description: Erfordert Permission `bookings_delete`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{id}/report-dataset:
    get:
      tags: [booking]
      summary: Aufbereitetes Auftragsdokument-Datenset abrufen
      description: |
        Liefert ein grosses, heterogenes Datenset fuer Auftragsdokumente
        (Lieferschein/Rechnung/etc.), das dieselbe Datenbasis wie die
        Berichts-Engine nutzt. Erfordert Permission `bookings_view`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: part_number
          in: query
          schema:
            type: integer
            minimum: 1
        - name: part_mode
          in: query
          schema:
            type: string
            enum: [delivery, billing]
        - name: position_mode
          in: query
          schema:
            type: string
            enum: [types, devices, all]
      responses:
        '200':
          description: Datenset
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: true
                    description: |
                      `meta` (Contract-Info), `document`, `supplier`, `customer`,
                      `delivery`, `invoice`, `recipient`, `positions[]`, `statistics`.
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  meta:
                    contract: "order-document"
                    schema_version: "1.0"
                    generated_at: "2026-07-01T09:00:00.000000Z"
                    locale: "de-DE"
                  document:
                    number: "ORD-2026-001"
                    date: "2026-02-28"
                  supplier: {}
                  customer:
                    id: "00000000-0000-4000-a000-000000000001"
                    name: "Test GmbH"
                  delivery: {}
                  invoice: {}
                  recipient: {}
                  positions: []
                  statistics: {}
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{id}/audits:
    get:
      tags: [booking]
      summary: Aenderungsverlauf eines Auftrags abrufen
      description: Erfordert Permission `bookings_view`. Sortiert `timestamp` DESC.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: "filter[field_name]"
          in: query
          schema:
            type: string
        - name: "filter[type]"
          in: query
          schema:
            type: string
        - name: "filter[from]"
          in: query
          description: Unix-Zeitstempel (inklusive)
          schema:
            type: integer
        - name: "filter[to]"
          in: query
          description: Unix-Zeitstempel (inklusive)
          schema:
            type: integer
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Audit-Eintraege des Auftrags
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{id}/mail-logs:
    get:
      tags: [booking]
      summary: E-Mail-Verlauf eines Auftrags abrufen
      description: |
        Alle `MailerMessage`-Eintraege mit `link_table` `booking`/`bookings` und
        passender `link_id`. Hand-gebautes Antwortformat (keine
        `MailerMessageResource`-Wiederverwendung, da mehr Felder enthalten
        sind). Erfordert Permission `bookings_view`. Sortiert `created_at` DESC.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche (LIKE) auf `subject`/`to_address`
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: E-Mail-Verlauf
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        type:
                          type: string
                          enum: [mailer-message]
                        attributes:
                          type: object
                          properties:
                            template_id:
                              type: integer
                              nullable: true
                            configuration_id:
                              type: integer
                              nullable: true
                            to_address:
                              type: string
                              format: email
                            cc_address:
                              type: string
                              nullable: true
                            bcc_address:
                              type: string
                              nullable: true
                            subject:
                              type: string
                            status:
                              type: integer
                              description: "1 = versendet, 0 = fehlgeschlagen"
                            error_message:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            link_table:
                              type: string
                            link_id:
                              type: string
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
              example:
                data:
                  - id: 42
                    type: mailer-message
                    attributes:
                      template_id: 1
                      configuration_id: 1
                      to_address: "kunde@example.com"
                      cc_address: null
                      bcc_address: null
                      subject: "Ihr Auftrag ORD-2026-001"
                      status: 1
                      error_message: null
                      created_at: "2026-07-01T09:00:00.000000Z"
                      link_table: booking
                      link_id: "00000000-0000-4000-a000-000000000b01"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{id}/activity-log:
    get:
      tags: [booking]
      summary: Benachrichtigungs- und Ereignisverlauf eines Auftrags abrufen
      description: |
        Zusammengefuehrte Zeitachse aus vier Quellen, neuestes zuerst:

        - `status` — Statuswechsel aus dem `audit`-Log plus Anlage des Auftrags.
          Kanonische Status werden fachlich formuliert („Angebot abgegeben").
        - `mail` — versendete E-Mails (`mailer_message`, beide `link_table`-
          Schreibweisen `booking`/`bookings`).
        - `notification` — Glocken-Benachrichtigungen (`user_notifications`)
          mit `link_type = booking`.
        - `document` — Belege aus dem DMS (`dms`), jede Version ein eigenes
          Ereignis. Die Auswahl entspricht dem Dokumente-Tab: Mustertreffer
          ohne `link_id` gehoeren dazu, nicht freigegebene Versionen nur fuer
          Betrachter mit Recht am DMS-Fach (`file_setting.role_name`/
          `group_id`).

        Grenzen: Audit-Zeilen entstehen nur mit angemeldetem Benutzer
        (`config/audit.php::require_user`). Schreibvorgaenge ohne HTTP-Kontext
        (Konsolenbefehle, Seeder, Queue-Jobs) liefern deshalb einen
        Anlage-Eintrag aus `bookings.created_at` ohne `actor`; Statuswechsel von
        vor Einfuehrung des Audit-Logs fehlen. Das Angebotsportal ist **nicht**
        betroffen: es schreibt als Dienstbenutzer (Rolle `api_integration`) und
        erscheint mit diesem als `actor`.

        Erfordert Permission `bookings_view`. Der aeltere `/mail-logs` bleibt
        als reine Mail-Teilmenge bestehen.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: "filter[channel]"
          in: query
          description: Auf einen Kanal einschraenken. Unbekannte Werte werden ignoriert.
          schema:
            type: string
            enum: [status, mail, notification, document]
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche (case-insensitive) auf `title`, `description`, `recipient`, `actor`, `to_address`
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Ereignisverlauf
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          description: "Quellen-praefigiert: `status-<uID>`, `mail-<id>`, `notification-<uuid>`, `document-<uID>`, `created`"
                        type:
                          type: string
                          enum: [booking-activity]
                        attributes:
                          type: object
                          properties:
                            channel:
                              type: string
                              enum: [status, mail, notification, document]
                            event:
                              type: string
                              description: "`created`, `status_changed`, `mail_sent`, `mail_failed`, `document_uploaded`, `document_approved` oder der Benachrichtigungstyp"
                            occurred_at:
                              type: string
                              format: date-time
                            title:
                              type: string
                              nullable: true
                            description:
                              type: string
                              nullable: true
                            outcome:
                              type: string
                              enum: [done, failed, pending]
                              description: "`pending` = abgelegt, aber nicht freigegeben (nur Dokumentkanal)"
                            actor:
                              type: string
                              nullable: true
                              description: Ausloesender Benutzer, `null` bei Schreibvorgaengen ohne HTTP-Kontext (Konsole, Seeder, Queue)
                            recipient:
                              type: string
                              nullable: true
                            from_status:
                              type: string
                              nullable: true
                            to_status:
                              type: string
                              nullable: true
                            subject:
                              type: string
                              nullable: true
                            to_address:
                              type: string
                              nullable: true
                            cc_address:
                              type: string
                              nullable: true
                            bcc_address:
                              type: string
                              nullable: true
                            error_message:
                              type: string
                              nullable: true
                            mail_status:
                              type: integer
                              nullable: true
                              description: "1 = versendet, 0 = fehlgeschlagen; `null` ausserhalb des Mail-Kanals"
                            document_id:
                              type: string
                              nullable: true
                              description: "DMS-uID des Belegs; `null` ausserhalb des Dokumentkanals"
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
                      channel_counts:
                        type: object
                        description: Anzahl je Kanal ueber den **ungefilterten** Verlauf
                        properties:
                          status:
                            type: integer
                          mail:
                            type: integer
                          notification:
                            type: integer
                          document:
                            type: integer
              example:
                data:
                  - id: "status-9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f60"
                    type: booking-activity
                    attributes:
                      channel: status
                      event: status_changed
                      occurred_at: "2026-07-02 11:15:00"
                      title: "Angebot abgegeben"
                      description: "Angebotsanfrage → Angebot"
                      outcome: done
                      actor: "Petra Muster"
                      recipient: null
                      from_status: "Angebotsanfrage"
                      to_status: "Angebot"
                      subject: null
                      to_address: null
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: null
                      document_id: null
                  - id: "document-9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f61"
                    type: booking-activity
                    attributes:
                      channel: document
                      event: document_uploaded
                      occurred_at: "2026-07-02 11:16:00"
                      title: "Beleg abgelegt: Angebot ANG-1.pdf"
                      description: "Version 0.1 · PDF · nicht freigegeben"
                      outcome: pending
                      actor: "Petra Muster"
                      recipient: null
                      from_status: null
                      to_status: null
                      subject: "Angebot ANG-1.pdf"
                      to_address: null
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: null
                      document_id: "9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f61"
                  - id: "mail-42"
                    type: booking-activity
                    attributes:
                      channel: mail
                      event: mail_sent
                      occurred_at: "2026-07-01 09:00:00"
                      title: "Ihr Auftrag ORD-2026-001"
                      description: null
                      outcome: done
                      actor: null
                      recipient: "kunde@example.com"
                      from_status: null
                      to_status: null
                      subject: "Ihr Auftrag ORD-2026-001"
                      to_address: "kunde@example.com"
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: 1
                      document_id: null
                meta:
                  total: 3
                  page: 1
                  per_page: 25
                  last_page: 1
                  channel_counts:
                    status: 1
                    mail: 1
                    notification: 0
                    document: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/repairs/{rtag}/activity-log:
    get:
      tags: [repair]
      summary: Benachrichtigungs- und Ereignisverlauf einer Reparatur abrufen
      description: |
        Zusammengefuehrte Zeitachse aus vier Quellen, neuestes zuerst:

        - `status` — Statuswechsel aus dem `audit`-Log plus Anlage der Reparatur.
          **Ohne** fachliche Formulierungen, anders als beim Auftrag: die
          Statusliste ist Mandantenkonfiguration, der Titel lautet daher
          `Status: <Titel>`.
        - `mail` — versendete E-Mails (`mailer_message`, `link_table` = `repairs`).
        - `notification` — Glocken-Benachrichtigungen (`user_notifications`)
          mit `link_type = repair`.
        - `document` — Belege aus dem DMS (`dms`), jede Version ein eigenes
          Ereignis. Die Auswahl entspricht dem Dokumente-Tab: Mustertreffer
          ohne `link_id` gehoeren dazu, nicht freigegebene Versionen nur fuer
          Betrachter mit Recht am DMS-Fach (`file_setting.role_name`/
          `group_id`).

        Grenzen: Audit-Zeilen entstehen nur mit angemeldetem Benutzer
        (`config/audit.php::require_user`). Schreibvorgaenge ohne HTTP-Kontext
        (Konsolenbefehle, Seeder, Queue-Jobs) liefern deshalb einen
        Anlage-Eintrag aus `bookings.created_at` ohne `actor`; Statuswechsel von
        vor Einfuehrung des Audit-Logs fehlen. Das Angebotsportal ist **nicht**
        betroffen: es schreibt als Dienstbenutzer (Rolle `api_integration`) und
        erscheint mit diesem als `actor`.

        Erfordert Permission `repair_view`. Der Datensatz wird kundengebunden
        aufgeloest, ein fremder Schluessel liefert 404 statt fremder Historie.
      parameters:
        - name: rtag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: "filter[channel]"
          in: query
          description: Auf einen Kanal einschraenken. Unbekannte Werte werden ignoriert.
          schema:
            type: string
            enum: [status, mail, notification, document]
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche (case-insensitive) auf `title`, `description`, `recipient`, `actor`, `to_address`
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Ereignisverlauf
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          description: "Quellen-praefigiert: `status-<uID>`, `mail-<id>`, `notification-<uuid>`, `document-<uID>`, `created`"
                        type:
                          type: string
                          enum: [repair-activity]
                        attributes:
                          type: object
                          properties:
                            channel:
                              type: string
                              enum: [status, mail, notification, document]
                            event:
                              type: string
                              description: "`created`, `status_changed`, `mail_sent`, `mail_failed`, `document_uploaded`, `document_approved` oder der Benachrichtigungstyp"
                            occurred_at:
                              type: string
                              format: date-time
                            title:
                              type: string
                              nullable: true
                            description:
                              type: string
                              nullable: true
                            outcome:
                              type: string
                              enum: [done, failed, pending]
                              description: "`pending` = abgelegt, aber nicht freigegeben (nur Dokumentkanal)"
                            actor:
                              type: string
                              nullable: true
                              description: Ausloesender Benutzer, `null` bei Schreibvorgaengen ohne HTTP-Kontext (Konsole, Seeder, Queue)
                            recipient:
                              type: string
                              nullable: true
                            from_status:
                              type: string
                              nullable: true
                            to_status:
                              type: string
                              nullable: true
                            subject:
                              type: string
                              nullable: true
                            to_address:
                              type: string
                              nullable: true
                            cc_address:
                              type: string
                              nullable: true
                            bcc_address:
                              type: string
                              nullable: true
                            error_message:
                              type: string
                              nullable: true
                            mail_status:
                              type: integer
                              nullable: true
                              description: "1 = versendet, 0 = fehlgeschlagen; `null` ausserhalb des Mail-Kanals"
                            document_id:
                              type: string
                              nullable: true
                              description: "DMS-uID des Belegs; `null` ausserhalb des Dokumentkanals"
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
                      channel_counts:
                        type: object
                        description: Anzahl je Kanal ueber den **ungefilterten** Verlauf
                        properties:
                          status:
                            type: integer
                          mail:
                            type: integer
                          notification:
                            type: integer
                          document:
                            type: integer
              example:
                data:
                  - id: "status-9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f60"
                    type: repair-activity
                    attributes:
                      channel: status
                      event: status_changed
                      occurred_at: "2026-07-02 11:15:00"
                      title: "Status: In Arbeit"
                      description: "Offen → In Arbeit"
                      outcome: done
                      actor: "Petra Muster"
                      recipient: null
                      from_status: "Offen"
                      to_status: "In Arbeit"
                      subject: null
                      to_address: null
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: null
                      document_id: null
                  - id: "document-9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f61"
                    type: repair-activity
                    attributes:
                      channel: document
                      event: document_uploaded
                      occurred_at: "2026-07-02 11:16:00"
                      title: "Beleg abgelegt: Angebot ANG-1.pdf"
                      description: "Version 0.1 · PDF · nicht freigegeben"
                      outcome: pending
                      actor: "Petra Muster"
                      recipient: null
                      from_status: null
                      to_status: null
                      subject: "Angebot ANG-1.pdf"
                      to_address: null
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: null
                      document_id: "9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f61"
                  - id: "mail-42"
                    type: repair-activity
                    attributes:
                      channel: mail
                      event: mail_sent
                      occurred_at: "2026-07-01 09:00:00"
                      title: "Ihr Auftrag ORD-2026-001"
                      description: null
                      outcome: done
                      actor: null
                      recipient: "kunde@example.com"
                      from_status: null
                      to_status: null
                      subject: "Ihr Auftrag ORD-2026-001"
                      to_address: "kunde@example.com"
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: 1
                      document_id: null
                meta:
                  total: 3
                  page: 1
                  per_page: 25
                  last_page: 1
                  channel_counts:
                    status: 1
                    mail: 1
                    notification: 0
                    document: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/calibrations/{ctag}/activity-log:
    get:
      tags: [calibration]
      summary: Benachrichtigungs- und Ereignisverlauf einer Kalibrierung abrufen
      description: |
        Zusammengefuehrte Zeitachse aus vier Quellen, neuestes zuerst:

        - `status` — Statuswechsel aus dem `audit`-Log plus Anlage der Kalibrierung.
          **Ohne** fachliche Formulierungen, anders als beim Auftrag: die
          Statusliste ist Mandantenkonfiguration, der Titel lautet daher
          `Status: <Titel>`.
        - `mail` — versendete E-Mails (`mailer_message`, `link_table` = `calibrations`).
        - `notification` — Glocken-Benachrichtigungen (`user_notifications`)
          mit `link_type = calibration`.
        - `document` — Belege aus dem DMS (`dms`), jede Version ein eigenes
          Ereignis. Die Auswahl entspricht dem Dokumente-Tab: Mustertreffer
          ohne `link_id` gehoeren dazu, nicht freigegebene Versionen nur fuer
          Betrachter mit Recht am DMS-Fach (`file_setting.role_name`/
          `group_id`).

        Grenzen: Audit-Zeilen entstehen nur mit angemeldetem Benutzer
        (`config/audit.php::require_user`). Schreibvorgaenge ohne HTTP-Kontext
        (Konsolenbefehle, Seeder, Queue-Jobs) liefern deshalb einen
        Anlage-Eintrag aus `bookings.created_at` ohne `actor`; Statuswechsel von
        vor Einfuehrung des Audit-Logs fehlen. Das Angebotsportal ist **nicht**
        betroffen: es schreibt als Dienstbenutzer (Rolle `api_integration`) und
        erscheint mit diesem als `actor`.

        Erfordert Permission `calibration_view`. Der Datensatz wird kundengebunden
        aufgeloest, ein fremder Schluessel liefert 404 statt fremder Historie.
      parameters:
        - name: ctag
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: "filter[channel]"
          in: query
          description: Auf einen Kanal einschraenken. Unbekannte Werte werden ignoriert.
          schema:
            type: string
            enum: [status, mail, notification, document]
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche (case-insensitive) auf `title`, `description`, `recipient`, `actor`, `to_address`
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Ereignisverlauf
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          description: "Quellen-praefigiert: `status-<uID>`, `mail-<id>`, `notification-<uuid>`, `document-<uID>`, `created`"
                        type:
                          type: string
                          enum: [calibration-activity]
                        attributes:
                          type: object
                          properties:
                            channel:
                              type: string
                              enum: [status, mail, notification, document]
                            event:
                              type: string
                              description: "`created`, `status_changed`, `mail_sent`, `mail_failed`, `document_uploaded`, `document_approved` oder der Benachrichtigungstyp"
                            occurred_at:
                              type: string
                              format: date-time
                            title:
                              type: string
                              nullable: true
                            description:
                              type: string
                              nullable: true
                            outcome:
                              type: string
                              enum: [done, failed, pending]
                              description: "`pending` = abgelegt, aber nicht freigegeben (nur Dokumentkanal)"
                            actor:
                              type: string
                              nullable: true
                              description: Ausloesender Benutzer, `null` bei Schreibvorgaengen ohne HTTP-Kontext (Konsole, Seeder, Queue)
                            recipient:
                              type: string
                              nullable: true
                            from_status:
                              type: string
                              nullable: true
                            to_status:
                              type: string
                              nullable: true
                            subject:
                              type: string
                              nullable: true
                            to_address:
                              type: string
                              nullable: true
                            cc_address:
                              type: string
                              nullable: true
                            bcc_address:
                              type: string
                              nullable: true
                            error_message:
                              type: string
                              nullable: true
                            mail_status:
                              type: integer
                              nullable: true
                              description: "1 = versendet, 0 = fehlgeschlagen; `null` ausserhalb des Mail-Kanals"
                            document_id:
                              type: string
                              nullable: true
                              description: "DMS-uID des Belegs; `null` ausserhalb des Dokumentkanals"
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
                      channel_counts:
                        type: object
                        description: Anzahl je Kanal ueber den **ungefilterten** Verlauf
                        properties:
                          status:
                            type: integer
                          mail:
                            type: integer
                          notification:
                            type: integer
                          document:
                            type: integer
              example:
                data:
                  - id: "status-9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f60"
                    type: calibration-activity
                    attributes:
                      channel: status
                      event: status_changed
                      occurred_at: "2026-07-02 11:15:00"
                      title: "Status: In Arbeit"
                      description: "Offen → In Arbeit"
                      outcome: done
                      actor: "Petra Muster"
                      recipient: null
                      from_status: "Offen"
                      to_status: "In Arbeit"
                      subject: null
                      to_address: null
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: null
                      document_id: null
                  - id: "document-9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f61"
                    type: calibration-activity
                    attributes:
                      channel: document
                      event: document_uploaded
                      occurred_at: "2026-07-02 11:16:00"
                      title: "Beleg abgelegt: Angebot ANG-1.pdf"
                      description: "Version 0.1 · PDF · nicht freigegeben"
                      outcome: pending
                      actor: "Petra Muster"
                      recipient: null
                      from_status: null
                      to_status: null
                      subject: "Angebot ANG-1.pdf"
                      to_address: null
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: null
                      document_id: "9f1c2c4e-0a3d-4f0b-9d6a-1b2c3d4e5f61"
                  - id: "mail-42"
                    type: calibration-activity
                    attributes:
                      channel: mail
                      event: mail_sent
                      occurred_at: "2026-07-01 09:00:00"
                      title: "Ihr Auftrag ORD-2026-001"
                      description: null
                      outcome: done
                      actor: null
                      recipient: "kunde@example.com"
                      from_status: null
                      to_status: null
                      subject: "Ihr Auftrag ORD-2026-001"
                      to_address: "kunde@example.com"
                      cc_address: null
                      bcc_address: null
                      error_message: null
                      mail_status: 1
                      document_id: null
                meta:
                  total: 3
                  page: 1
                  per_page: 25
                  last_page: 1
                  channel_counts:
                    status: 1
                    mail: 1
                    notification: 0
                    document: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{id}/collective-orders:
    get:
      tags: [booking]
      summary: Verknuepfte Sammelauftraege abrufen
      description: Erfordert Permission `bookings_view`. Nicht paginiert, `meta.total` nur.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Verknuepfte Auftraege
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        type:
                          type: string
                          enum: [collective-invoice]
                        attributes:
                          type: object
                          properties:
                            link_id:
                              type: string
                            booking_id:
                              type: string
                            booking_number:
                              type: string
                            booking_status:
                              type: string
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
                    type: collective-invoice
                    attributes:
                      link_id: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
                      booking_id: "00000000-0000-4000-a000-000000000b05"
                      booking_number: "ORD-2026-005"
                      booking_status: invoice
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{id}/assign-order:
    post:
      tags: [booking]
      summary: Auftraege als Sammelauftrag verknuepfen
      description: |
        Bereits verknuepfte Paare werden still uebersprungen. Ist eine der
        `booking_ids` unbekannt, antwortet der **gesamte** Request mit 404
        ("Booking not found."). Erfordert Permission `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [booking_ids]
              properties:
                booking_ids:
                  type: array
                  minItems: 1
                  items:
                    type: string
                  example: ["00000000-0000-4000-a000-000000000b05"]
      responses:
        '201':
          description: Verknuepft
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  created:
                    type: integer
              example:
                success: true
                created: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/bookings/{id}/unassign-order/{otherBookingId}:
    delete:
      tags: [booking]
      summary: Sammelauftrags-Verknuepfung entfernen
      description: Erfordert Permission `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: otherBookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b05"
      responses:
        '204':
          description: Erfolgreich entfernt
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Verknuepfung nicht gefunden — verschachteltes Fehlerformat (nicht das
            Standard-RFC7807-`NotFound`, da hand-gebaut).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 404
                  title: Not Found
                  detail: "Collective invoice link not found."

  /api/v2/bookings/{bookingId}/customer-documents:
    get:
      tags: [booking]
      summary: Kundensichtbare Belege eines Auftrags
      description: |
        Erfordert Permission `bookings_view`.

        Der Kanal fuer Kundenportale (eForms-Adapter). Liefert **nur**
        Dokumente aus DMS-Faechern mit `file_setting.customer_visible`, nur
        freigegeben (`status = 1`) und nur die jeweils letzte Version.

        Bewusst ein eigener Endpunkt statt eines Filters an
        `GET /bookings/{id}/documents`: der liefert alles, was am Auftrag
        liegt, interne Ablagen und Entwuerfe eingeschlossen. Ein vergessener
        Query-Parameter darf nicht die Grenze zwischen intern und Kunde sein.

        Ausgeliefert sind vier kundensichtbare Faecher (`booking_offer`,
        `booking_order_confirmation`, `booking_delivery_note`,
        `booking_invoice`); `booking_attachment` ist intern.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - $ref: '#/components/parameters/pageNumber'
        - $ref: '#/components/parameters/pageSize'
      responses:
        '200':
          description: Kundensichtbare Belege
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v2/bookings/{bookingId}/customer-documents/{documentId}/download:
    get:
      tags: [booking]
      summary: Kundensichtbaren Beleg herunterladen
      description: |
        Erfordert Permission `bookings_view`.

        Dieselbe Pruefung wie die Liste, noch einmal am Server: gehoert das
        Dokument nicht zu diesem Auftrag oder nicht in ein kundensichtbares,
        freigegebenes Fach, antwortet der Endpunkt mit 404. Sich darauf zu
        verlassen, dass der Client nur gelistete Ids anfasst, waere eine
        Absichtserklaerung, keine Grenze.

        Erfasste DMS-Signaturen werden wie beim internen Download in das PDF
        eingebrannt.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: documentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000d01"
      responses:
        '200':
          description: Dateidownload
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v2/bookings/{bookingId}/totals:
    get:
      tags: [booking]
      summary: Auftragssummen (Netto, Rabatt, Zuschlag, USt. je Satz, Brutto)
      description: |
        Erfordert Permission `bookings_view`.

        Liefert dieselbe Rechnung wie der Beleg (`statistics` im Report-Contract
        `order-document`), damit Bildschirm und PDF nicht auseinanderlaufen.
        Die Arithmetik folgt EN 16931: der Auftragsrabatt mindert die
        Bemessungsgrundlage und wird anteilig auf die Steuergruppen verteilt,
        die Versandkosten des Auftrags sind ein Zuschlag auf Dokumentebene.
        Clients duerfen die Werte **nicht** selbst nachrechnen — Rabatt und
        Versandkosten haengen am Auftrag, nicht an der Positionsliste.

        Die Schluessel sind die Business Terms der EN 16931: `net_total` BT-106,
        `discount_amount` BT-107, `charge_total` BT-108, `net_taxable` BT-109,
        `vat_total` BT-110, `gross_total` BT-112, je Steuergruppe `net` BT-116
        und `vat` BT-117.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Summen und Zeilennetti
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      positions:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            line_net:
                              type: number
                      statistics:
                        type: object
                        properties:
                          tax_groups:
                            type: array
                            items:
                              type: object
                              properties:
                                rate:
                                  type: number
                                line_net:
                                  type: number
                                allowance:
                                  type: number
                                charge:
                                  type: number
                                net:
                                  type: number
                                vat:
                                  type: number
                          net_total:
                            type: number
                          order_discount_percent:
                            type: number
                          discount_amount:
                            type: number
                          net_after_discount:
                            type: number
                          charge_total:
                            type: number
                          net_taxable:
                            type: number
                          vat_total:
                            type: number
                          gross_total:
                            type: number
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  positions:
                    - id: "00000000-0000-4000-a000-000000000a01"
                      line_net: 200.0
                    - id: "00000000-0000-4000-a000-000000000a02"
                      line_net: 45.0
                  statistics:
                    tax_groups:
                      - rate: 7.0
                        line_net: 45.0
                        allowance: 4.5
                        charge: 0.0
                        net: 40.5
                        vat: 2.84
                      - rate: 19.0
                        line_net: 200.0
                        allowance: 20.0
                        charge: 0.0
                        net: 180.0
                        vat: 34.2
                    net_total: 245.0
                    order_discount_percent: 10.0
                    discount_amount: 24.5
                    net_after_discount: 220.5
                    charge_total: 0.0
                    net_taxable: 220.5
                    vat_total: 37.04
                    gross_total: 257.54
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v2/bookings/{id}/parts:
    get:
      tags: [booking]
      summary: Positionsnummern je Teil-Liefer-/Rechnungslauf abrufen
      description: Erfordert Permission `bookings_view`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Teile-Uebersicht
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      delivery:
                        type: array
                        items:
                          type: integer
                      billing:
                        type: array
                        items:
                          type: integer
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  delivery: [1, 2]
                  billing: [1]
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [booking]
      summary: Artikelpositionen einem Teillauf zuweisen
      description: Erfordert Permission `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode, article_ids]
              properties:
                mode:
                  type: string
                  enum: [delivery, billing]
                article_ids:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    maxLength: 36
      responses:
        '200':
          description: Zugewiesen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      mode:
                        type: string
                      part:
                        type: integer
                      positions:
                        type: integer
                        description: Anzahl der tatsaechlich zugeordneten Positionen. 0 bedeutet, dass keine der uebergebenen IDs zu diesem Auftrag gehoert.
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  mode: delivery
                  part: 2
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/bookings/{id}/next-actions:
    get:
      tags: [booking]
      summary: Moegliche naechste Workflow-Schritte ermitteln
      description: |
        Zeigt den aktuellen Workflow-Stand, Positions-Fortschritt und erlaubte
        Ziel-Status inkl. Sperrgruenden (`reasons`). Erfordert Permission
        `bookings_view`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Naechste Aktionen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      current:
                        type: object
                        properties:
                          status:
                            type: string
                          title:
                            type: string
                          stage:
                            type: string
                      progress:
                        type: object
                        properties:
                          positions:
                            type: integer
                          delivered:
                            type: integer
                          billed:
                            type: integer
                          open_delivery:
                            type: integer
                          open_billing:
                            type: integer
                      actions:
                        type: array
                        items:
                          type: object
                          properties:
                            status:
                              type: string
                            title:
                              type: string
                            stage:
                              type: string
                            allowed:
                              type: boolean
                            reasons:
                              type: array
                              items:
                                type: object
                                properties:
                                  code:
                                    type: string
                                    description: |
                                      `order_stage_required`, `no_positions`,
                                      `customer_required`, `no_delivery_part`
                                  message:
                                    type: string
                      recommended:
                        type: string
                        nullable: true
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  current:
                    status: "c1c2c3c4-0000-4000-a000-000000000001"
                    title: "Angebot"
                    stage: offer
                  progress:
                    positions: 3
                    delivered: 0
                    billed: 0
                    open_delivery: 3
                    open_billing: 3
                  actions:
                    - status: "c1c2c3c4-0000-4000-a000-000000000002"
                      title: "Auftrag"
                      stage: order
                      allowed: true
                      reasons: []
                    - status: "c1c2c3c4-0000-4000-a000-000000000003"
                      title: "Rechnung"
                      stage: invoice
                      allowed: false
                      reasons:
                        - code: customer_required
                          message: "Dem Vorgang ist kein Kunde zugeordnet."
                  recommended: "c1c2c3c4-0000-4000-a000-000000000002"
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/inventories/{mtag}/bookings:
    get:
      tags: [booking]
      summary: Auftragshistorie eines Inventarobjekts abrufen
      description: |
        Alle Auftraege, die eine Artikelposition mit `article_ID=mtag` und
        `article_type=inventory` enthalten (Geraet-Bestellhistorie). Gleiche
        Paginierung wie `GET /bookings`, aber ohne Filter. Erfordert Permission
        `bookings_view`.
      parameters:
        - $ref: '#/components/parameters/mtag'
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Auftragsliste des Inventarobjekts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/generate-number:
    post:
      tags: [booking]
      summary: Naechste Auftragsnummer ermitteln
      description: |
        Erzeugt einen Nummernvorschlag anhand der fuer `status` oder `category`
        konfigurierten Nummernkreis-Vorgabe (genau eines von beiden angeben).
        Erfordert Permission `bookings_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  maxLength: 36
                  description: Pflichtfeld, falls `category` fehlt
                category:
                  type: string
                  maxLength: 36
                  description: Pflichtfeld, falls `status` fehlt
            example:
              status: offer
      responses:
        '200':
          description: Nummernvorschlag (oder `generated:false`, wenn keine Vorgabe konfiguriert ist)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      number:
                        type: string
                        nullable: true
                      generated:
                        type: boolean
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  number: "2026-00042"
                  generated: true
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/bookings/create-with-inventories:
    post:
      tags: [booking]
      summary: Auftrag mit vorbelegten Inventarpositionen anlegen
      description: |
        Wie `POST /bookings`, legt zusaetzlich fuer jede `inventory_id` eine
        Artikelposition an. Erfordert Permission `bookings_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/BookingInput'
                - type: object
                  required: [inventory_ids]
                  properties:
                    inventory_ids:
                      type: array
                      minItems: 1
                      items:
                        type: string
                        maxLength: 80
                      example: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
      responses:
        '201':
          description: |
            Auftrag erstellt. Zusaetzlich `meta.inventory_count`; ggf.
            `meta.warnings.open_orders` (offene Auftraege je Geraet).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
                  meta:
                    type: object
                    properties:
                      inventory_count:
                        type: integer
                      warnings:
                        type: object
                        properties:
                          open_orders:
                            type: array
                            items:
                              type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/bookings/create-from-notepad:
    post:
      tags: [booking]
      summary: Auftrag aus Notizbuch-Eintraegen anlegen
      description: |
        Wie `POST /bookings`, uebernimmt Geraetebezuege aus `notepad_ids`.
        Erfordert Permission `bookings_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/BookingInput'
                - type: object
                  required: [notepad_ids]
                  properties:
                    notepad_ids:
                      type: array
                      minItems: 1
                      items:
                        type: string
                        maxLength: 36
      responses:
        '201':
          description: Auftrag erstellt (zusaetzlich `meta.inventory_count`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
                  meta:
                    type: object
                    properties:
                      inventory_count:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            RFC7807-Validierungsfehler, oder — NESTED-Fehlerformat, wenn keiner
            der Notizbuch-Eintraege ein Geraet referenziert.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: Validation Error
                  detail: "Die gewählten Notizen verweisen auf kein Gerät."

  /api/v2/bookings/{id}/change-status:
    post:
      tags: [booking]
      summary: Status per Workflow wechseln
      description: |
        Prueft zuerst `BookingTransitionPolicy` (kann den Uebergang unabhaengig
        von Pflichtfeldern blockieren, z.B. fehlende Positionen); erst danach
        greift — wie bei Repair — der StatusTransitionService mit moeglichem
        `data.required_fields_missing`. Erfordert Permission `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  maxLength: 36
      responses:
        '200':
          description: Statuswechsel erfolgreich (BookingResource + `meta.transition`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Drei moegliche Formen: RFC7807-Validierungsfehler; von
            `BookingTransitionPolicy` blockierter Uebergang
            (`data.workflow_blocked` + `data.reasons`); oder — wie bei Repair —
            `data.required_fields_missing` + `data.required_fields`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success:
                        type: boolean
                      workflow_blocked:
                        type: boolean
                      reasons:
                        type: array
                        items:
                          type: object
                          properties:
                            code:
                              type: string
                            message:
                              type: string
                      required_fields_missing:
                        type: boolean
                      required_fields:
                        type: array
                        items:
                          type: string
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  success: false
                  workflow_blocked: true
                  reasons:
                    - code: no_positions
                      message: "Der Vorgang hat keine Positionen."
                meta:
                  api_version: "2.0"

  /api/v2/bookings/{id}/additional-fields:
    post:
      tags: [booking]
      summary: Statuswechsel mit Zusatzfeldern speichern
      description: |
        Wie `POST .../change-status`, uebergibt zusaetzlich Werte fuer die beim
        Zielstatus als Pflicht markierten Felder in einem Schritt. Durchlaeuft
        ebenfalls zuerst `BookingTransitionPolicy`. Erfordert Permission
        `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, fields]
              properties:
                status:
                  type: string
                  maxLength: 36
                fields:
                  type: object
                  additionalProperties: true
      responses:
        '200':
          description: Uebernommen (BookingResource + `meta.transition`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
                  meta:
                    type: object
                    properties:
                      transition:
                        type: object
                        additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Wie bei `POST .../change-status`: `data.workflow_blocked` oder
            `data.required_fields_missing` (oder RFC7807-Validierungsfehler).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: true
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string

  /api/v2/bookings/{id}/cancel:
    post:
      tags: [booking]
      summary: Auftrag stornieren
      description: |
        Setzt `status` hart auf `cancelled`, ohne Request-Body und ohne
        `BookingTransitionPolicy`-Pruefung. Erfordert Permission `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Storniert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{id}/duplicate:
    post:
      tags: [booking]
      summary: Auftrag duplizieren
      description: |
        Kein Request-Body. Neue UUID, `modified`/`modified_by` werden
        zurueckgesetzt, `number` wird unveraendert kopiert. Erfordert
        Permission `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '201':
          description: Duplikat erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BookingResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            Nur wenn eine Property-Einstellung eindeutige Auftragsnummern
            erzwingt und die Quelle bereits eine doppelte Nummer hat.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 409
                  title: Conflict
                  detail: "Duplicate booking numbers are not allowed. Change the booking number before duplicating."

  /api/v2/bookings/{id}/sign:
    post:
      tags: [booking]
      summary: Auftragsbericht signieren
      description: |
        Erfordert Permission `bookings_edit`. Antwortformat unterscheidet sich
        bewusst von `POST /repairs/{rtag}/sign` — kein `data`/`meta`-Envelope.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature, setting_id, report_id]
              properties:
                signature:
                  type: string
                  example: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
                setting_id:
                  type: string
                  maxLength: 36
                report_id:
                  type: string
                  maxLength: 36
                  description: Anders als bei Repair hier Pflichtfeld
                width:
                  type: integer
                height:
                  type: integer
      responses:
        '200':
          description: Signiert (kein `data`/`meta`-Envelope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  signature_id:
                    type: string
              example:
                success: true
                signature_id: "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/bookings/{bookingId}/standard-articles:
    post:
      tags: [booking]
      summary: Standardartikel als Position uebernehmen
      description: |
        Kopiert Menge/Preis/Steuer/Einheit/Rabatt/Versandkosten/Kalibrierart/
        Kommentar aus dem Standardartikel-Katalog in eine neue Artikelposition.
        Kein `article_id_label` im Response (im Unterschied zu den anderen
        Artikel-Erstellungswegen). Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [standard_article_id]
              properties:
                standard_article_id:
                  type: string
                  description: UUID des Standardartikels (`findOrFail`)
      responses:
        '201':
          description: Artikelposition erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ArticleResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/bookings/bulk-delete:
    post:
      tags: [booking]
      summary: Mehrere Auftraege loeschen
      description: |
        Erfordert Permission `bookings_delete`. **Ungeprueft:** `deleted`
        entspricht der Anzahl angefragter IDs, nicht der tatsaechlich
        geloeschten Zeilen; `skipped`/`skipped_ids` sind immer `0`/`[]`.
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # BOOKING REPORTS
  # ──────────────────────────────────────────────
  /api/v2/bookings/{bookingId}/reports:
    get:
      tags: [booking]
      summary: Verfuegbare Berichte eines Auftrags auflisten
      description: |
        Wie `GET /repairs/{rtag}/reports` (GRID_NAME `booking`). Erfordert
        Permission `bookings_view`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Berichtseinstellungen
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportSettingResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e"
                    type: report-setting
                    attributes:
                      button_name: "Auftragsbestätigung"
                      menu: "Master"
                      format: pdf
                      folder_name: orders
                      report_name: "order_confirmation.jrxml"
                      is_label: false
                      is_print: false
                      email_attachment: true
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{bookingId}/reports/{reportId}/parameters:
    get:
      tags: [booking]
      summary: Prompt-Parameter eines Berichts abrufen
      description: |
        Wie `GET /repairs/{rtag}/reports/{reportId}/parameters`. Erfordert
        Permission `bookings_view`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: reportId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e"
      responses:
        '200':
          description: Berichtsparameter
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReportParameterResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            `ReportSetting` nicht gefunden oder fuer die Rolle/Gruppe nicht
            sichtbar (generisches `detail` im zweiten Fall).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/404"
                title: Not Found
                status: 404
                detail: "ReportSetting not found."

  /api/v2/bookings/{bookingId}/reports/{reportId}/preview:
    get:
      tags: [booking]
      summary: Bericht als PDF-Vorschau rendern
      description: |
        Wie `GET /repairs/{rtag}/reports/{reportId}/preview`, zusaetzlich
        `part_number`/`part_mode`/`position_mode` (siehe
        `GET /bookings/{id}/report-dataset`). Erfordert Permission `bookings_view`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: reportId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e"
        - name: "parameters[Name]"
          in: query
          schema:
            type: string
        - name: part_number
          in: query
          schema:
            type: integer
            minimum: 1
        - name: part_mode
          in: query
          schema:
            type: string
            enum: [delivery, billing]
        - name: position_mode
          in: query
          schema:
            type: string
            enum: [types, devices, all]
      responses:
        '200':
          description: PDF-Datei (inline)
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."

  /api/v2/bookings/{bookingId}/reports/generate:
    post:
      tags: [booking]
      summary: Bericht generieren und herunterladen
      description: |
        Wie `POST /repairs/{rtag}/reports/generate`, zusaetzlich `part`
        (`ALL`/`PARTIAL`, Standard `ALL`). Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                part:
                  type: string
                  enum: [ALL, PARTIAL]
                  default: ALL
                parameters:
                  type: object
                  additionalProperties:
                    type: string
      responses:
        '200':
          description: Binaerer Datei-Download
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."

  /api/v2/bookings/{bookingId}/reports/save-to-dms:
    post:
      tags: [booking]
      summary: Bericht generieren und im DMS ablegen
      description: Wie `POST /repairs/{rtag}/reports/save-to-dms`. Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                file_setting_id:
                  type: string
                parameters:
                  type: object
                  additionalProperties:
                    type: string
      responses:
        '201':
          description: Im DMS abgelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DocumentResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: DMS-Ablage fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "DMS-Ablage fehlgeschlagen"
                  detail: "Es konnte keine passende Dateieinstellung ermittelt werden."
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."

  /api/v2/bookings/{bookingId}/reports/release:
    post:
      tags: [booking]
      summary: Bericht generieren und freigeben
      description: Wie `POST /repairs/{rtag}/reports/release`. Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                file_setting_id:
                  type: string
                parameters:
                  type: object
                  additionalProperties:
                    type: string
      responses:
        '201':
          description: Freigegeben — ein `DocumentResource` je zutreffender Freigabe-Regel
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocumentResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: DMS-Ablage fehlgeschlagen (ggf. mit `candidates`) oder keine Freigabe-Regel konfiguriert
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 422
                  title: "Keine Freigabe konfiguriert"
                  detail: "Für diesen Bericht ist keine Freigabe-Regel für Ihre Rolle/Gruppe konfiguriert."
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."

  /api/v2/bookings/{bookingId}/reports/send:
    post:
      tags: [booking]
      summary: Bericht generieren und per E-Mail versenden
      description: |
        Wie `POST /repairs/{rtag}/reports/send`. `attributes` der
        `MailerMessageResource`-Antwort enthalten zusaetzlich `link_table`/
        `link_id`. Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report_id, to_address, subject, body]
              properties:
                report_id:
                  type: string
                  format: uuid
                format:
                  type: string
                  enum: [pdf, xlsx, docx, csv]
                to_address:
                  type: string
                  format: email
                  maxLength: 255
                subject:
                  type: string
                  maxLength: 255
                body:
                  type: string
                cc_address:
                  type: string
                  nullable: true
                  maxLength: 255
                bcc_address:
                  type: string
                  nullable: true
                  maxLength: 255
                parameters:
                  type: object
                  additionalProperties:
                    type: string
      responses:
        '201':
          description: Versendet
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/MailerMessageResource'
              example:
                data:
                  id: "43"
                  type: mailer-message
                  attributes:
                    status: sent
                    error_message: null
                    to_address: "kunde@example.com"
                    subject: "Ihr Auftrag ORD-2026-001"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '502':
          description: Berichtsgenerierung fehlgeschlagen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 502
                  title: Report Generation Failed
                  detail: "The report engine returned a non-zero exit code."
        '503':
          description: Mailer-Konfiguration fehlt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  status: 503
                  title: "Mailer Configuration Missing"
                  detail: "No mailer configuration found for this tenant."

  # ──────────────────────────────────────────────
  # ARTICLES (Auftragspositionen)
  # ──────────────────────────────────────────────
  /api/v2/bookings/{bookingId}/articles:
    get:
      tags: [booking]
      summary: Artikelpositionen eines Auftrags auflisten
      description: |
        Sortiert `pos_number` ASC. **Keine** Paginierung — liefert eine reine
        `{"data":[...]}`-Antwort ohne `meta`/`links`. Erfordert Permission
        `bookings_view`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Artikelpositionen (kein `meta`, kein `links`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ArticleResource'
              example:
                data:
                  - id: "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f"
                    type: article
                    attributes:
                      booking_id: "00000000-0000-4000-a000-000000000b01"
                      article_id: "test-article-1"
                      article_type: article
                      complexity: "DAkkS-Kalibrierung"
                      quantity: 1
                      price: 89.5
                      tax: 19
                      unit: "EA"
                      discount: 0
                      costs: 0
                      delivery_costs: 0
                      pos_number: 1
                      delivery_part: null
                      delivery_date: null
                      billing_part: null
                      billing_date: null
                      calibration_type: ""
                      comment: ""
                      description: null
                      article_id_label: "Kalibrierung Standard"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [booking]
      summary: Artikelposition anlegen
      description: |
        Kein FormRequest — Inline-Validierung. Erfordert Permission
        `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ArticleInput'
            example:
              article_id: "test-article-1"
              article_type: article
              quantity: 1
              price: 89.5
              tax: 19
              unit: "EA"
      responses:
        '201':
          description: Artikelposition erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ArticleResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/articles/{id}:
    get:
      tags: [booking]
      summary: Einzelne Artikelposition abrufen
      description: Erfordert Permission `bookings_view`.
      parameters:
        - name: id
          in: path
          required: true
          description: UUID der Artikelposition
          schema:
            type: string
            format: uuid
          example: "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f"
      responses:
        '200':
          description: Artikelposition
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ArticleResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [booking]
      summary: Artikelposition aktualisieren
      description: |
        Partial Update, Inline-Validierung (identische Regeln wie beim
        Anlegen, `article_id` hier optional). Erfordert Permission
        `bookings_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ArticleInput'
            example:
              quantity: 2
              price: 79.0
      responses:
        '200':
          description: Artikelposition aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ArticleResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [booking]
      summary: Artikelposition loeschen
      description: |
        Erfordert Permission `bookings_delete`. Loest — anders als die
        uebrigen Loesch-Endpunkte in dieser Domain — **kein**
        `ResourceDeleted`-Event aus.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{bookingId}/articles/{articleId}/price-context:
    get:
      tags: [price]
      summary: Preiskontext einer Artikelposition ermitteln
      description: |
        Zeigt den zugrundeliegenden Geraetetyp, Kunde/Preisgruppe und passende
        Preise. Fuer `article_type=type`-Positionen sind `type_id`/`type_name`
        `null` und `prices` leer. Erfordert Permission `price_view`
        (**nicht** `bookings_view`, wie die uebrigen Artikel-Endpunkte).
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
        - name: articleId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f"
      responses:
        '200':
          description: Preiskontext
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      article_id:
                        type: string
                      article_type:
                        type: string
                      type_id:
                        type: string
                        nullable: true
                      type_name:
                        type: string
                        nullable: true
                      customer_id:
                        type: string
                        nullable: true
                      customer_category_id:
                        type: string
                        nullable: true
                      prices:
                        type: array
                        items:
                          $ref: '#/components/schemas/PriceResource'
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  article_id: "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f"
                  article_type: type
                  type_id: "test-type-multimeter"
                  type_name: "Fluke 87V"
                  customer_id: "00000000-0000-4000-a000-000000000001"
                  customer_category_id: "test-price-cat-1"
                  prices:
                    - id: "test-price-1"
                      type: price
                      attributes:
                        type_id: "test-type-multimeter"
                        category_id: "test-price-cat-1"
                        price: 120
                        tax: 19
                        unit: "EA"
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{bookingId}/articles/bulk:
    post:
      tags: [booking]
      summary: Mehrere Inventarobjekte als Artikelpositionen uebernehmen
      description: Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [inventory_ids]
              properties:
                inventory_ids:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    maxLength: 80
      responses:
        '200':
          description: Positionen angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created:
                        type: integer
                      skipped:
                        type: integer
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  created: 2
                  skipped: 0
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/bookings/{bookingId}/articles/refresh-descriptions:
    post:
      tags: [booking]
      summary: Artikelbeschreibungen neu aus dem Stammdatenbestand ziehen
      description: Kein Request-Body. Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      updated:
                        type: integer
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  updated: 3
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/bookings/{bookingId}/types:
    post:
      tags: [booking]
      summary: Geraetetyp als Artikelposition hinzufuegen
      description: |
        Ermittelt den Preis ueber die Preismatrix; ohne Treffer Fallback
        `price:0`, `tax:19`, `unit:EA`. Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type_id]
              properties:
                type_id:
                  type: string
                  maxLength: 80
                quantity:
                  type: number
            example:
              type_id: "test-type-multimeter"
              quantity: 1
      responses:
        '201':
          description: Artikelposition erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ArticleResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/bookings/{bookingId}/articles/actualize-prices:
    post:
      tags: [booking]
      summary: Preise aller Artikelpositionen neu ermitteln
      description: Kein Request-Body. Erfordert Permission `bookings_edit`.
      parameters:
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "00000000-0000-4000-a000-000000000b01"
      responses:
        '200':
          description: Aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      updated:
                        type: integer
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data:
                  updated: 3
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # PRICES
  # ──────────────────────────────────────────────
  /api/v2/prices:
    get:
      tags: [price]
      summary: Preise auflisten
      description: |
        Hand-gebaute Filter (kein FilterService); Sortierung fest
        `modified_at` DESC. Erfordert Permission `price_view`.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: "filter[type_id]"
          in: query
          schema:
            type: string
        - name: "filter[category_id]"
          in: query
          schema:
            type: string
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche (LIKE) auf `type.type_name`
          schema:
            type: string
        - $ref: '#/components/parameters/pageSize'
        - $ref: '#/components/parameters/pageNumber'
      responses:
        '200':
          description: Preisliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PriceCollection'
              example:
                data:
                  - id: "test-price-1"
                    type: price
                    attributes:
                      type_id: "test-type-multimeter"
                      category_id: "test-price-cat-1"
                      price: 120
                      complexity: null
                      price_min: 0
                      price_max: 0
                      start_date: null
                      end_date: null
                      costs: 0
                      quantity: 1
                      unit: "EA"
                      discount: 0
                      tax: 19
                      created_by: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                      modified_by: null
                      modified_at: null
                      type_id_label: "Fluke 87V"
                      category_id_label: "Standard Preise"
                      created_by_label: "Test Admin"
                meta:
                  total: 1
                  page: 1
                  per_page: 25
                  last_page: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [price]
      summary: Preis anlegen
      description: Erfordert Permission `price_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PriceInput'
            example:
              type_id: "test-type-torque"
              category_id: "test-price-cat-2"
              price: 200.00
              quantity: 0.5
      responses:
        '201':
          description: Preis erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            RFC7807-Validierungsfehler (`ValidatesPriceConsistency`). Typische
            Feldmeldungen: `errors.type_id[0]`: "Für diesen Gerätetyp, diese
            Preisgruppe, dieses Startdatum und diese Komplexität existiert
            bereits ein Preis."; `errors.end_date[0]`: "Das Enddatum der
            Gültigkeit darf nicht vor dem Startdatum liegen."; `errors.price_min[0]`:
            "Der Mindestpreis darf nicht über dem Höchstpreis liegen."
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/422"
                title: Validation Error
                status: 422
                detail: "The given data was invalid."
                errors:
                  type_id:
                    - "Für diesen Gerätetyp, diese Preisgruppe, dieses Startdatum und diese Komplexität existiert bereits ein Preis."

  /api/v2/prices/lookup:
    get:
      tags: [price]
      summary: Anwendbaren Preis nachschlagen
      description: |
        Ermittelt den zum Zeitpunkt `date` gueltigen Preis fuer Typ/Kunde/
        Komplexitaet. Erfordert Permission `price_view`.

        Mit **`service_id`** fragt der Endpunkt die Leistungsachse
        (`service_prices`, AP 10) statt der Kalibrierung; `meta.matched_by`
        nennt dann `service_type_price`, `service_class_price`,
        `service_class_procedure_price` oder `service_flat_price`. Auf der
        Leistungsachse ist `type_id` optional — eine Versandpauschale hat
        keinen Gerätetyp. Ohne `service_id` bleibt `type_id` Pflicht.

        Auf der **Kalibrierung** meldet `meta.matched_by` seit AP 11 Teil 2b
        `type_price`, `class_price` oder `class_procedure_price`. Die
        Stufennamen sind je Achse verschieden, weil es zwei getrennte Kaskaden
        sind — wer `service_class_price` auf der Kalibrierung sähe, müsste
        raten, ob er versehentlich eine Leistungsantwort bekommen hat.

        **`data.type` ist nicht immer `price`.** Trifft die Kategoriestufe,
        liegt die Zeile physisch in `service_prices` und `data.type` meldet
        `service-price`. Wer den Betrag ändern will, muss den dazu passenden
        Endpunkt ansprechen — `/service-prices/{id}` statt `/prices/{id}`.
        Kategoriezeilen der Kalibrierung tragen dort eine leere `service_id`.
      parameters:
        - name: service_id
          in: query
          description: standard_article.uID; schaltet auf die Leistungsachse
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: type_id
          in: query
          description: Pflicht ohne `service_id`
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: customer_id
          in: query
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: complexity
          in: query
          schema:
            type: string
            maxLength: 255
            nullable: true
        - name: date
          in: query
          description: "`YYYY-MM-DD`, Standard heute"
          schema:
            type: string
            format: date
            nullable: true
      responses:
        '200':
          description: "Gefundener Preis, oder `data:null`, wenn keiner zutrifft"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceResource'
                    nullable: true
                  meta:
                    type: object
                    properties:
                      api_version:
                        type: string
              example:
                data: null
                meta:
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/prices/matrix:
    get:
      tags: [price]
      summary: Preismatrix (Geraetetyp x Preisgruppe) abrufen
      description: |
        Query-Parameter nutzen **`page`/`per_page`** (nicht `page[size]`/
        `page[number]` wie sonst ueberall in der API — bewusste Inkonsistenz).
        Eine in `rows[].cells` fehlende Kategorie-ID bedeutet "kein Preis
        hinterlegt", nicht `null`. Erfordert Permission `price_view`.

        Mit **`service_id`** zeigt dieselbe Matrix die Leistungsachse aus
        `service_prices` statt der Kalibrierung aus `prices` (AP 10).

        Eine Zelle rechnet auf **beiden** Achsen ueber die volle Zugriffsfolge,
        und `cells[].matched_by` sagt, welche Stufe getroffen hat — ein Betrag,
        der aus einer Pauschale oder einer Preiskategorie stammt, sieht sonst
        wie ein gepflegter Einzelpreis aus. Auf der Kalibrierung ist das Feld
        seit AP 11 Teil 2b gesetzt (vorher nur auf der Leistungsachse).

        **Nur ein Typpreis darf aus der Zelle heraus geaendert werden.** Ein
        Kategoriepreis gilt fuer alle Mitglieder seiner Kategorie und liegt
        zudem in `service_prices` — sein `price_id` griffe an `/prices/{id}`
        ins Leere. Der Lueckenfilter (`only_gaps`) zaehlt entsprechend: ein
        Geraetetyp, der ueber seine Kategorie bepreist ist, ist keine Luecke.
      parameters:
        - name: service_id
          in: query
          description: standard_article.uID; ohne Angabe die Kalibrierung aus `prices`
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: date
          in: query
          schema:
            type: string
            format: date
            nullable: true
        - name: complexity
          in: query
          schema:
            type: string
            maxLength: 255
            nullable: true
        - name: search
          in: query
          schema:
            type: string
            maxLength: 255
            nullable: true
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 25
      responses:
        '200':
          description: Preismatrix
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      categories:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            name:
                              type: string
                      rows:
                        type: array
                        items:
                          type: object
                          properties:
                            type_id:
                              type: string
                            type_name:
                              type: string
                            cells:
                              type: object
                              additionalProperties:
                                type: object
                                properties:
                                  price_id:
                                    type: string
                                  price:
                                    type: number
                                  tax:
                                    type: number
                                  unit:
                                    type: string
                                  complexity:
                                    type: string
                                    nullable: true
                                  start_date:
                                    type: string
                                    nullable: true
                                  end_date:
                                    type: string
                                    nullable: true
                                  matched_by:
                                    description: >-
                                      Welche Stufe der Zugriffsfolge die Zelle
                                      gefuellt hat. Auf beiden Achsen gesetzt;
                                      die Stufennamen unterscheiden sich, weil
                                      es zwei getrennte Kaskaden sind.
                                    type: string
                                    enum:
                                      - type_price
                                      - class_price
                                      - class_procedure_price
                                      - service_type_price
                                      - service_class_price
                                      - service_class_procedure_price
                                      - service_flat_price
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      last_page:
                        type: integer
                      date:
                        type: string
                        nullable: true
                      complexity:
                        type: string
                        nullable: true
                      covered_cells:
                        type: integer
                      total_cells:
                        type: integer
                      service_id:
                        type: string
                        nullable: true
                      axis:
                        type: string
                        enum: [calibration, service]
                      api_version:
                        type: string
              example:
                data:
                  categories:
                    - id: "test-price-cat-1"
                      name: "Standard Preise"
                  rows:
                    - type_id: "test-type-multimeter"
                      type_name: "Fluke 87V"
                      cells:
                        test-price-cat-1:
                          price_id: "test-price-1"
                          price: 120
                          tax: 19
                          unit: "EA"
                          complexity: null
                          start_date: null
                          end_date: null
                meta:
                  total: 12
                  page: 1
                  per_page: 25
                  last_page: 1
                  date: null
                  complexity: null
                  covered_cells: 8
                  total_cells: 25
                  api_version: "2.0"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v2/prices/{id}:
    get:
      tags: [price]
      summary: Einzelnen Preis abrufen
      description: Erfordert Permission `price_view`.
      parameters:
        - $ref: '#/components/parameters/includeV1Names'
        - $ref: '#/components/parameters/v1FieldNamesHeader'
        - name: id
          in: path
          required: true
          description: "`pID` des Preises"
          schema:
            type: string
          example: "test-price-1"
      responses:
        '200':
          description: Preis
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [price]
      summary: Preis aktualisieren
      description: "Partial Update, `type_id`/`category_id` neu zuweisbar. Erfordert Permission `price_edit`."
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          example: "test-price-1"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PriceInput'
            example:
              price: 125.00
      responses:
        '200':
          description: Preis aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [price]
      summary: Preis loeschen
      description: Erfordert Permission `price_delete`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          example: "test-price-1"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/prices/{id}/duplicate:
    post:
      tags: [price]
      summary: Preis duplizieren
      description: |
        `type_id`/`category_id`/`price`/`complexity`/`start_date`/`end_date`
        sind optional ueberschreibbar, alle uebrigen Felder werden von der
        Quelle kopiert. Erfordert Permission `price_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          example: "test-price-1"
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PriceInput'
            example:
              category_id: "test-price-cat-2"
      responses:
        '201':
          description: Duplikat erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/prices/bulk-delete:
    post:
      tags: [price]
      summary: Mehrere Preise loeschen
      description: |
        Erfordert Permission `price_delete`. **Ungeprueft:** `deleted`
        entspricht der Anzahl angefragter IDs (siehe Hinweis bei
        `POST /bookings/bulk-delete`); `skipped`/`skipped_ids` immer `0`/`[]`.
      requestBody:
        $ref: '#/components/requestBodies/BulkDeleteRequest'
      responses:
        '200':
          $ref: '#/components/responses/BulkDeleteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  # ──────────────────────────────────────────────
  # SERVICE PRICES (Leistungsachse, AP 10)
  # ──────────────────────────────────────────────
  /api/v2/service-prices:
    get:
      tags: [price]
      summary: Leistungspreise auflisten
      description: |
        Bepreisung auf der Leistungsachse: Leistung x Geraetetyp x Preisgruppe
        x Gueltigkeit (Konzept 3.5). Ein leerer `type_id` ist eine Pauschale
        ohne Geraetebezug, eine leere `category_id` gilt ohne Preisgruppe.

        Die Attribute tragen neben den Schluesseln die Beschriftungen
        `service_id_label`, `type_id_label` und `category_id_label`; eine
        geloeschte Referenz liefert `null`, nie die UUID. Erfordert Permission
        `price_view`.
      parameters:
        - name: filter[service_id]
          in: query
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: filter[type_id]
          in: query
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: filter[category_id]
          in: query
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: filter[price_class_id]
          in: query
          description: Nur Zeilen dieser Preiskategorie (AP 11)
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: filter[search]
          in: query
          description: Sucht ueber den Namen der Leistung (standard_article.article_name)
          schema:
            type: string
            maxLength: 255
            nullable: true
      responses:
        '200':
          description: Liste der Leistungspreise
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ServicePriceResource'
                  meta:
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [price]
      summary: Leistungspreis anlegen
      description: |
        `service_id` ist Pflicht: eine Zeile ohne Leistung waere inhaltlich die
        Kalibrierung, und die steht in `prices`. Erfordert Permission
        `price_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ServicePriceInput'
      responses:
        '201':
          description: Angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ServicePriceResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/service-prices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [price]
      summary: Leistungspreis abrufen
      description: Erfordert Permission `price_view`.
      responses:
        '200':
          description: Leistungspreis
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ServicePriceResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [price]
      summary: Leistungspreis aendern
      description: Erfordert Permission `price_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ServicePriceInput'
      responses:
        '200':
          description: Geaendert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ServicePriceResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [price]
      summary: Leistungspreis loeschen
      description: Erfordert Permission `price_delete`.
      responses:
        '200':
          description: Geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # PRICE CLASSES (Preiskategorie, Container der Geraeteachse, AP 11)
  # ──────────────────────────────────────────────
  /api/v2/price-classes:
    get:
      tags: [price]
      summary: Preiskategorien auflisten
      description: |
        Die Preiskategorie ist der **Container der Geraeteachse** (ADR-032):
        sie nimmt Geraetetypen direkt und Prozeduren auf und traegt den Preis.
        Hierarchisch ueber `parent_id`, Ebenen optional (Fachgebiet, Gattung,
        Bereichs-/Genauigkeitsstufe).

        Nicht zu verwechseln mit `/price-categories` — das ist die
        **Kundenpreisgruppe**, also die andere Achse. Die Namensgleichheit ist
        historisch; das Schema behaelt die unterscheidbaren Bezeichner.

        Sortierung fest: `sort_order`, `number`, `name`, `id`. Erfordert
        Permission `price_view`.
      parameters:
        - name: filter[parent_id]
          in: query
          description: Nur die Unterkategorien dieser Kategorie
          schema:
            type: string
            maxLength: 80
            nullable: true
        - name: filter[roots]
          in: query
          description: Nur Kategorien der obersten Ebene
          schema:
            type: boolean
        - name: filter[search]
          in: query
          description: Sucht ueber Nummer und Name
          schema:
            type: string
            maxLength: 255
            nullable: true
      responses:
        '200':
          description: Liste der Preiskategorien
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PriceClassResource'
                  meta:
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [price]
      summary: Preiskategorie anlegen
      description: |
        Nur `name` ist Pflicht. `number` ist eindeutig unter den Kategorien,
        die eine tragen — mehrere Kategorien ohne Nummer sind erlaubt.
        Erfordert Permission `price_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PriceClassInput'
      responses:
        '201':
          description: Angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceClassResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/price-classes/resolve:
    get:
      tags: [price]
      summary: Preiskategorien eines Geraetetyps aufloesen
      description: |
        Welche Kategorien kommen fuer diesen Geraetetyp in Frage, in welcher
        Reihenfolge? Die Liste ist die Zugriffsfolge der Stufe: die **direkte**
        Zuordnung zuerst, danach die ueber die Standardprozedur geerbte, jeweils
        von der feinsten Ebene aufwaerts. Erste Fundstelle gewinnt.

        Beantwortet „warum kostet das 82 Euro?" fuer die Kategoriestufe, ohne
        dass der Anwender die Zugriffsfolge kennen muss. Erfordert Permission
        `price_view`.
      parameters:
        - name: type_id
          in: query
          required: true
          schema:
            type: string
            maxLength: 80
      responses:
        '200':
          description: Kandidatenliste, feinste Stufe zuerst
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        price_class_id:
                          type: string
                        price_class_id_label:
                          type: string
                          nullable: true
                        origin:
                          type: string
                          enum: [direct, procedure]
                          description: >-
                            `direct` = der Geraetetyp liegt selbst in der
                            Kategorie, `procedure` = geerbt ueber seine
                            Standardprozedur
                        depth:
                          type: integer
                          description: 0 = die zugeordnete Kategorie, 1 = ihr Elternteil, ...
                        inherited:
                          type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/price-classes/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [price]
      summary: Preiskategorie abrufen
      description: Erfordert Permission `price_view`.
      responses:
        '200':
          description: Preiskategorie
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceClassResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [price]
      summary: Preiskategorie aendern
      description: |
        Ein `parent_id`, das die Kategorie unter eine ihrer eigenen
        Unterkategorien haengen wuerde, wird mit 422 abgewiesen — ein Ring
        machte den Zweig oberhalb des Bruchs unbepreisbar. Erfordert Permission
        `price_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PriceClassInput'
      responses:
        '200':
          description: Geaendert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceClassResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [price]
      summary: Preiskategorie loeschen
      description: |
        Blockiert mit 409, solange Unterkategorien, Mitglieder oder Preiszeilen
        daran haengen. Erfordert Permission `price_delete`.
      responses:
        '200':
          description: Geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Es haengt noch etwas an der Kategorie

  /api/v2/price-classes/{id}/members:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [price]
      summary: Mitglieder einer Preiskategorie auflisten
      description: Erfordert Permission `price_view`.
      responses:
        '200':
          description: Liste der Mitglieder
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PriceClassMemberResource'
                  meta:
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [price]
      summary: Geraetetyp oder Prozedur zusortieren
      description: |
        Ein Geraetetyp gehoert zu **hoechstens einer** Preiskategorie, eine
        Prozedur ebenso. Liegt das Mitglied bereits in einer anderen Kategorie,
        wird es umgehaengt statt abgewiesen; `meta.moved` sagt, ob das der Fall
        war (Antwort dann 200 statt 201). Erfordert Permission `price_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PriceClassMemberInput'
      responses:
        '200':
          description: Umgehaengt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceClassMemberResource'
                  meta:
                    type: object
                    properties:
                      moved:
                        type: boolean
        '201':
          description: Zusortiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceClassMemberResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/price-classes/{id}/members/{memberId}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: memberId
        in: path
        required: true
        schema:
          type: string
    delete:
      tags: [price]
      summary: Mitglied aus der Preiskategorie nehmen
      description: Erfordert Permission `price_edit`.
      responses:
        '200':
          description: Entfernt
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ──────────────────────────────────────────────
  # PRICE CATEGORIES
  # ──────────────────────────────────────────────
  /api/v2/price-categories:
    get:
      tags: [price]
      summary: Preisgruppen auflisten
      description: |
        Hand-gebaute Filterung; Sortierung fest `name` ASC. **Nicht
        paginiert** — liefert alle Treffer, `meta.total` nur. Erfordert
        Permission `price_category_view`.
      parameters:
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche (LIKE) auf `name`
          schema:
            type: string
      responses:
        '200':
          description: Preisgruppenliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PriceCategoryResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "test-price-cat-1"
                    type: price-category
                    attributes:
                      name: "Standard Preise"
                      description: "Standard Preisgruppe"
                      created_by: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                      modified_by: null
                      modified_at: null
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [price]
      summary: Preisgruppe anlegen
      description: |
        Kein FormRequest, hand-gebaute Antwort (`{"data":{...}}`, kein
        `meta`). Erfordert Permission `price_category_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 255
                  description: Pflichtfeld, eindeutig
                description:
                  type: string
                  maxLength: 500
                  nullable: true
            example:
              name: "Sonderkonditionen"
              description: "Preisgruppe fuer Rahmenvertraege"
      responses:
        '201':
          description: Preisgruppe erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceCategoryResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/price-categories/{id}:
    get:
      tags: [price]
      summary: Einzelne Preisgruppe abrufen
      description: |
        Zusaetzlich zu `attributes` liefert die Detailansicht
        `meta.statistics` (`customer_count`, `price_count`, `type_count`,
        `covered_types`). Erfordert Permission `price_category_view`.
      parameters:
        - $ref: '#/components/parameters/categoryId'
      responses:
        '200':
          description: Preisgruppe inkl. Statistik
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceCategoryResource'
                  meta:
                    type: object
                    properties:
                      statistics:
                        type: object
                        properties:
                          customer_count:
                            type: integer
                          price_count:
                            type: integer
                          type_count:
                            type: integer
                          covered_types:
                            type: integer
              example:
                data:
                  id: "test-price-cat-1"
                  type: price-category
                  attributes:
                    name: "Standard Preise"
                    description: "Standard Preisgruppe"
                    created_by: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                    modified_by: null
                    modified_at: null
                meta:
                  statistics:
                    customer_count: 3
                    price_count: 42
                    type_count: 87
                    covered_types: 30
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [price]
      summary: Preisgruppe aktualisieren
      description: Partial Update. `name` bleibt eindeutig (ignoriert sich selbst). Erfordert Permission `price_category_edit`.
      parameters:
        - $ref: '#/components/parameters/categoryId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 255
                description:
                  type: string
                  maxLength: 500
                  nullable: true
            example:
              description: "Standard Preisgruppe (ueberarbeitet)"
      responses:
        '200':
          description: Preisgruppe aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceCategoryResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [price]
      summary: Preisgruppe loeschen
      description: Erfordert Permission `price_category_delete`.
      parameters:
        - $ref: '#/components/parameters/categoryId'
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/price-categories/{id}/duplicate:
    post:
      tags: [price]
      summary: Preisgruppe duplizieren (optional mit Preis-Faktor)
      description: |
        Erfordert Route-Permission `price_category_edit` **plus** eine
        zusaetzliche Controller-Pruefung auf `price_edit` (da beim
        Duplizieren neue Preise entstehen) — 403 mit flachem RFC7807-Body
        (via `abort()`, nicht die verschachtelte Middleware-Form), falls
        `price_edit` fehlt. `dry_run:true` schreibt nichts und antwortet
        trotzdem mit **200** (nicht 201).
      parameters:
        - $ref: '#/components/parameters/categoryId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 255
                  description: Pflichtfeld, eindeutig
                description:
                  type: string
                  maxLength: 500
                  nullable: true
                factor:
                  type: number
                  minimum: 0.01
                  maximum: 100
                  default: 1.0
                  description: Skaliert kopierte `price`/`price_min`/`price_max`/`costs`
                dry_run:
                  type: boolean
                  default: false
            example:
              name: "Sonderkonditionen 2027"
              factor: 0.9
      responses:
        '200':
          description: "Nur bei `dry_run:true` — `data:null`, keine Schreiboperation"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    nullable: true
                    example: null
                  meta:
                    type: object
                    properties:
                      dry_run:
                        type: boolean
                      name:
                        type: string
                      price_count:
                        type: integer
                      factor:
                        type: number
              example:
                data: null
                meta:
                  dry_run: true
                  name: "Sonderkonditionen 2027"
                  price_count: 42
                  factor: 1.0
        '201':
          description: Preisgruppe (inkl. skalierter Preiskopien) erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PriceCategoryResource'
                  meta:
                    type: object
                    properties:
                      dry_run:
                        type: boolean
                      price_count:
                        type: integer
                      factor:
                        type: number
              example:
                data:
                  id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  type: price-category
                  attributes:
                    name: "Sonderkonditionen 2027"
                    description: "Standard Preisgruppe"
                    created_by: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
                    modified_by: null
                    modified_at: null
                meta:
                  dry_run: false
                  price_count: 42
                  factor: 0.9
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            Route-Permission `price_category_edit` fehlt (verschachtelte
            Middleware-Form, siehe `Forbidden`) **oder** die zusaetzliche
            `price_edit`-Pruefung im Controller schlaegt fehl (flacher
            RFC7807-Body via `abort()`).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              example:
                type: "https://httpstatuses.com/403"
                title: Forbidden
                status: 403
                detail: "Missing required permission: price_edit"
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  # ──────────────────────────────────────────────
  # STANDARD ARTICLES (Katalog)
  # ──────────────────────────────────────────────
  /api/v2/standard-articles:
    get:
      tags: [price]
      summary: Standardartikel-Katalog auflisten
      description: |
        Hand-gebaute Filterung; Sortierung fest `article_name` ASC. **Nicht
        paginiert** — liefert alle Treffer, `meta.total` nur. Erfordert
        Permission `article_view`.
      parameters:
        - name: "filter[search]"
          in: query
          description: Teilstring-Suche (LIKE) auf `article_name` ODER `calibration_type`
          schema:
            type: string
      responses:
        '200':
          description: Standardartikelliste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/StandardArticleResource'
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
              example:
                data:
                  - id: "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a"
                    type: standard-article
                    attributes:
                      article_name: "Kalibrierung Standard"
                      quantity: 1
                      price: 150
                      discount: 0
                      delivery_costs: null
                      tax: 19
                      unit: "EA"
                      pos_number: null
                      calibration_type: null
                      comment: null
                meta:
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [price]
      summary: Standardartikel anlegen
      description: |
        Kein FormRequest, hand-gebaute Antwort (`{"data":{...}}`, kein
        `meta`). Defaults beim Anlegen: `quantity:1`, `price:0`,
        `discount:0`, `delivery_costs:0`, `tax:19`, `unit:"EA"`,
        `pos_number:1.00`. Erfordert Permission `article_edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [article_name]
              properties:
                article_name:
                  type: string
                  maxLength: 255
                  description: Pflichtfeld, eindeutig
                quantity:
                  type: integer
                price:
                  type: number
                discount:
                  type: integer
                  minimum: 0
                  maximum: 100
                  description: "Prozentualer Rabatt als Ganzzahl (anders als bei Price/Article, dort Dezimalzahl)"
                delivery_costs:
                  type: number
                tax:
                  type: integer
                unit:
                  type: string
                  maxLength: 10
                pos_number:
                  type: number
                calibration_type:
                  type: string
                  maxLength: 255
                  nullable: true
                comment:
                  type: string
                  nullable: true
            example:
              article_name: "Kalibrierung Standard"
              price: 150
              tax: 19
              unit: "EA"
      responses:
        '201':
          description: Standardartikel erstellt
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardArticleResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /api/v2/standard-articles/{id}:
    get:
      tags: [price]
      summary: Einzelnen Standardartikel abrufen
      description: Erfordert Permission `article_view`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a"
      responses:
        '200':
          description: Standardartikel
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardArticleResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [price]
      summary: Standardartikel aktualisieren
      description: Partial Update. `article_name` bleibt eindeutig (ignoriert sich selbst). Erfordert Permission `article_edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                article_name:
                  type: string
                  maxLength: 255
                quantity:
                  type: integer
                price:
                  type: number
                discount:
                  type: integer
                  minimum: 0
                  maximum: 100
                delivery_costs:
                  type: number
                tax:
                  type: integer
                unit:
                  type: string
                  maxLength: 10
                pos_number:
                  type: number
                calibration_type:
                  type: string
                  maxLength: 255
                  nullable: true
                comment:
                  type: string
                  nullable: true
            example:
              price: 160
      responses:
        '200':
          description: Standardartikel aktualisiert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/StandardArticleResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [price]
      summary: Standardartikel loeschen
      description: Erfordert Permission `article_delete`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a"
      responses:
        '204':
          description: Erfolgreich geloescht
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  # ──────────────────────────────────────────────
  # SECURITY SCHEMES
  # ──────────────────────────────────────────────
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        Sanctum API Token — erzeugen via `POST /api/v2/auth/token`.

        **Verwendung:** `Authorization: Bearer <token>`

  # ──────────────────────────────────────────────
  # PARAMETERS
  # ──────────────────────────────────────────────
  parameters:
    includeV1Names:
      name: include_v1_names
      in: query
      description: |
        `1`/`true`: Jedes Attribut zusaetzlich unter seinem V1-Code
        (METTRACK, z.B. `I4201`) ausliefern — additiv neben dem lesbaren
        Namen, siehe Kapitel "Feldnamen (API v2 vs. v1)". Gilt fuer Listen-
        und Einzelabrufe aller Registry-Ressourcen.
      schema:
        type: boolean
        default: false
    v1FieldNamesHeader:
      name: X-CalServer-V1-Field-Names
      in: header
      description: |
        Header-Variante von `include_v1_names` fuer Clients, die ihre URLs
        nicht anfassen koennen. `1`/`true` aktiviert die zusaetzlichen
        V1-Codes in der Antwort.
      schema:
        type: string
        enum: ["0", "1"]
    pageSize:
      name: "page[size]"
      in: query
      description: Datensaetze pro Seite (Standard 25, max 100)
      schema:
        type: integer
        default: 25
        maximum: 100
    pageNumber:
      name: "page[number]"
      in: query
      description: Seitennummer (Standard 1)
      schema:
        type: integer
        default: 1
    gridFields:
      name: fields
      in: query
      description: |
        Kommaliste der Felder, die die Antwort tragen soll. Ohne den Parameter
        enthaelt jede Zeile den vollstaendigen Attributsatz der Installation —
        auf einer gewachsenen Registry sind das ueber hundert Felder je Zeile,
        auch wenn der Aufrufer acht davon braucht.

        Die Einschraenkung wirkt nicht nur auf die Antwort: Der Server laedt
        nur die Relationen und liest nur die Spalten, die dafuer noetig sind.

        Ein Feld ist unter seinem api_name, seinem physischen Spaltennamen oder
        seinem V1-Code adressierbar — dieselbe Zusage wie bei Filter und
        Sortierung. Abgeleitete Werte kommen mit ihrem Feld: Wer `customer_id`
        anfordert, bekommt `customer_id_label` dazu, wer `status` anfordert
        `status_label`.

        Ein leerer Wert (`?fields=`) gilt als "keine Angabe" und liefert die
        vollstaendige Zeile.
      schema:
        type: string
      example: "asset_number,manufacturer,status"
    mtag:
      name: mtag
      in: path
      required: true
      description: MTAG (UUID) des Inventarobjekts
      schema:
        type: string
        format: uuid
      example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
    specificationId:
      name: id
      in: path
      required: true
      description: UUID der Spezifikation
      schema:
        type: string
        format: uuid
      example: "e5f6a7b8-1234-4c5d-9e0f-a1b2c3d4e5f6"
    specificationRevision:
      name: revision
      in: path
      required: true
      description: Nummer (`1`, `2`, …) oder UUID der Revision
      schema:
        type: string
      example: "2"
    ctag:
      name: ctag
      in: path
      required: true
      description: CTAG (UUID) der Kalibrierung
      schema:
        type: string
        format: uuid
      example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
    procedureBatchId:
      name: id
      in: path
      required: true
      description: UUID des Sammellaufs
      schema:
        type: string
        format: uuid
    categoryId:
      name: id
      in: path
      required: true
      description: UUID der Kategorie
      schema:
        type: string
        format: uuid
      example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
    tID:
      name: tID
      in: path
      required: true
      description: UUID des Geraetetyps
      schema:
        type: string
        format: uuid
      example: "b3e1c2d3-4567-8901-abcd-ef1234567890"
    statusId:
      name: id
      in: path
      required: true
      description: UUID des Status
      schema:
        type: string
        format: uuid
      example: "c6f35048-6556-5dba-602c-10eed9cb2473"
    testStandardId:
      name: id
      in: path
      required: true
      description: UUID der Pruefnorm
      schema:
        type: string
        format: uuid
      example: "b2c3d4e5-f6a7-4b89-9012-3c4d5e6f7a8b"

  # ──────────────────────────────────────────────
  # REQUEST BODIES
  # ──────────────────────────────────────────────
  requestBodies:
    BulkDeleteRequest:
      required: true
      description: Massenloeschung anhand einer Liste von IDs (max. 1000).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/BulkDeleteInput'
          example:
            ids: ["ea79b775-8b4b-8e17-98c9-b33e7065ff6f", "3fa85f64-5717-4562-b3fc-2c963f66afa6"]

  # ──────────────────────────────────────────────
  # RESPONSES
  # ──────────────────────────────────────────────
  responses:
    # Zwei Fehlerformate koexistieren bewusst (siehe ErrorResponse vs.
    # ProblemDetails weiter unten): 401/403 werden von der ApiAuthenticate-
    # Middleware VOR dem Controller erzeugt (verschachteltes `error`-Objekt);
    # 404/422/409 entstehen als Laravel-Exceptions und werden vom
    # ApiExceptionHandler als RFC7807 "Problem Details" (flach, mit `type`)
    # gerendert. Ein Client muss beide Formen unterscheiden koennen.
    Unauthorized:
      description: Fehlende oder ungueltige Authentifizierung
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              status: 401
              title: Unauthorized
              detail: "Invalid or missing authentication credentials."
    Forbidden:
      description: |
        Fehlende Berechtigung. `detail` unterscheidet drei Faelle:
        - `"Your account is not active."` — Konto deaktiviert
        - `"You do not have API access permission."` — Operation `using_API` fehlt
        - `"Missing required permission: {operation}"` — spezifische Operation fehlt
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              status: 403
              title: Forbidden
              detail: "Missing required permission: inventory_edit"
    NotFound:
      description: |
        Ressource nicht gefunden (RFC7807 Problem Details, `Content-Type: application/problem+json`).
        `detail` ist immer `"{Modellname} not found."`, z.B. `"Inventory not found."`, `"Category not found."`.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          example:
            type: "https://httpstatuses.com/404"
            title: Not Found
            status: 404
            detail: "Inventory not found."
    ValidationError:
      description: |
        Validierungsfehler (RFC7807 Problem Details mit zusaetzlichem `errors`-Objekt,
        `Content-Type: application/problem+json`). `detail` ist Laravels
        Standardmeldung `"The given data was invalid."`; die feldspezifischen
        Meldungen stehen in `errors`.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          example:
            type: "https://httpstatuses.com/422"
            title: Validation Error
            status: 422
            detail: "The given data was invalid."
            errors:
              name:
                - "The name field is required."
    Conflict:
      description: |
        Konflikt (RFC7807 Problem Details, `Content-Type: application/problem+json`).
        Typischerweise eine Fremdschluessel-/Zustandsverletzung, die als
        verstaendliche Meldung statt als 500 ausgeliefert wird.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          example:
            type: "https://httpstatuses.com/409"
            title: Conflict
            status: 409
            detail: "Inventory has related calibrations and cannot be deleted."
    BulkDeleteResult:
      description: |
        Ergebnis einer Massenloeschung. Je nach Ressource wird entweder pruefend
        (`skipped`/`skipped_ids` echt gefuellt) oder ungeprueft geloescht — dann
        entspricht `deleted` der Anzahl angefragter IDs, nicht zwingend der Anzahl
        tatsaechlich betroffener Zeilen. Details je Endpunkt in dessen Beschreibung.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/BulkDeleteResultSchema'
          example:
            deleted: 2
            skipped: 0
            skipped_ids: []
    SpecificationError:
      description: |
        Fachlicher Grund der Spezifikationsstrecke, verschachtelt unter
        `error`: stabiler `code` fuer Verzweigungen, deutscher Satz in
        `message`, Einzelheiten in `details` (etwa `issues` einer gescheiterten
        Freigabe oder die waehlbaren `options`).
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    example: specification_invalid
                  message:
                    type: string
                  details:
                    type: object
                    additionalProperties: true
          example:
            error:
              code: specification_option_unknown
              message: "Die Option „class-c“ ist für diese Kalibrierung nicht wählbar."
              details:
                options: [class-a, class-b]
    ProcedureBatchError:
      description: |
        Fachlicher Grund des Sammellaufs (RFC7807 Problem Details) mit `code`
        und Einzelheiten auf oberster Ebene, etwa den Befunden der Planpruefung
        je Schritt.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/ProblemDetails'
              - type: object
                properties:
                  code:
                    type: string
                additionalProperties: true

  # ──────────────────────────────────────────────
  # SCHEMAS
  # ──────────────────────────────────────────────
  schemas:
    ErrorResponse:
      description: |
        Fehlerformat der `ApiAuthenticate`-Middleware (401/403 — vor dem
        Controller abgefangen: fehlender/ungueltiger Token, inaktives Konto,
        fehlende Berechtigung).
      type: object
      properties:
        error:
          type: object
          properties:
            status:
              type: integer
            title:
              type: string
            detail:
              type: string

    ProblemDetails:
      description: |
        RFC7807 "Problem Details"-Format (`ApiExceptionHandler`), genutzt fuer
        404 (Model nicht gefunden), 422 (Validierung), 409 (Constraint-/
        Zustandsverletzung) und sonstige HTTP-Exceptions. Response-Header
        `Content-Type: application/problem+json`.
      type: object
      properties:
        type:
          type: string
          example: "https://httpstatuses.com/404"
        title:
          type: string
          example: "Not Found"
        status:
          type: integer
          example: 404
        detail:
          type: string
          example: "Inventory not found."
        errors:
          type: object
          nullable: true
          description: Nur bei Validierungsfehlern (422) vorhanden — Feldname zu Liste von Meldungen.
          additionalProperties:
            type: array
            items:
              type: string

    # ── Category ──────────────────────────────────
    CategoryAttributes:
      type: object
      properties:
        name:
          type: string
          description: Kategoriename
          example: "Messgeraet"
        short_name:
          type: string
          nullable: true
          description: Kurzname
          example: "MG"
        type:
          type: string
          enum: [inventory, booking, repair, calibration]
          description: Kategorietyp
          example: inventory
        parent_id:
          type: string
          format: uuid
          nullable: true
          description: UUID der uebergeordneten Kategorie (null = Hauptkategorie)
        sort_num:
          type: integer
          nullable: true
          description: Sortierreihenfolge
          example: 1
        color:
          type: string
          nullable: true
          description: Hintergrundfarbe (Hex)
          example: "#4CAF50"
        text_color:
          type: string
          nullable: true
          description: Textfarbe (Hex)
          example: "#FFFFFF"
        rentable:
          type: integer
          nullable: true
          description: "Ausleihbar (0 = nein, 1 = ja)"
          example: 1

    CategoryResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID der Kategorie
        type:
          type: string
          enum: [category]
        attributes:
          $ref: '#/components/schemas/CategoryAttributes'

    CategoryCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/CategoryResource'
        meta:
          type: object
          properties:
            total:
              type: integer

    CategoryInput:
      type: object
      required: [name, type]
      properties:
        name:
          type: string
          maxLength: 255
          description: Kategoriename (Pflichtfeld)
          example: "Messgeraet"
        short_name:
          type: string
          maxLength: 50
          description: Kurzname
          example: "MG"
        type:
          type: string
          enum: [inventory, booking, repair, calibration]
          description: Kategorietyp (Pflichtfeld)
          example: inventory
        parent_id:
          type: string
          format: uuid
          nullable: true
          description: UUID der Elternkategorie
        sort_num:
          type: integer
          description: Sortierreihenfolge
          example: 1
        color:
          type: string
          maxLength: 50
          description: Hintergrundfarbe (Hex)
          example: "#4CAF50"
        text_color:
          type: string
          maxLength: 50
          description: Textfarbe (Hex)
          example: "#FFFFFF"
        rentable:
          type: integer
          enum: [0, 1]
          description: "Ausleihbar (0 = nein, 1 = ja)"
          example: 1

    CategoryUpdate:
      type: object
      description: Partial-Update — nur uebergebene Felder werden geaendert
      properties:
        name:
          type: string
          maxLength: 255
        short_name:
          type: string
          maxLength: 50
        parent_id:
          type: string
          format: uuid
          nullable: true
        sort_num:
          type: integer
        color:
          type: string
          maxLength: 50
        text_color:
          type: string
          maxLength: 50
        rentable:
          type: integer
          enum: [0, 1]

    # ── Inventory ─────────────────────────────────
    InventoryAttributes:
      type: object
      description: |
        Inventar-Attribute mit sprechenden Feldnamen.
        Die Zuordnung zu Datenbank-Spalten (I4201, I4202, ...) ist dynamisch
        und kann ueber `GET /api/v2/fields/inventory` abgefragt werden.
      properties:
        asset_number:
          type: string
          description: Inventarnummer (I4201)
          example: "DMM-001"
        serial_number:
          type: string
          nullable: true
          description: Seriennummer (I4206)
          example: "SN-1234567890"
        description:
          type: string
          nullable: true
          description: Geraetebeschreibung (I4204)
          example: "Digital Multimeter"
        manufacturer:
          type: string
          nullable: true
          description: Hersteller (I4202)
          example: "FLUKE"
        model:
          type: string
          nullable: true
          description: Modell/Typ (I4203)
          example: "179"
        location:
          type: string
          nullable: true
          description: Standort (I4210)
          example: "Labor A"
        status:
          type: integer
          nullable: true
          description: Statuscode (I4211)
          example: 1
        calibration_interval:
          type: integer
          nullable: true
          description: Kalibrierintervall in Monaten (I4220)
          example: 12
        customer_id:
          type: string
          nullable: true
          description: Kunden-UUID (FK zu customer.KTAG)
          example: "45f09720-082f-5c74-9cfa-a605e2863904"
      additionalProperties: true

    InventoryRelationships:
      type: object
      properties:
        customer:
          type: object
          properties:
            data:
              type: object
              nullable: true
              properties:
                id:
                  type: string
                type:
                  type: string
                  enum: [customer]
        categories:
          type: array
          description: "Zugeordnete Kategorien (type=inventory)"
          items:
            $ref: '#/components/schemas/CategoryResource'

    InventoryResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: MTAG (UUID) des Inventarobjekts
        type:
          type: string
          enum: [inventory]
        attributes:
          $ref: '#/components/schemas/InventoryAttributes'
        relationships:
          $ref: '#/components/schemas/InventoryRelationships'

    InventoryCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/InventoryResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
        links:
          type: object
          properties:
            self:
              type: string
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    InventoryInput:
      type: object
      description: |
        Achtung: die Spaltenbreiten sind enger als der generische 255-Zeichen-Default
        (spiegeln `create_inventory_table`). Ein zu langer Wert liefert 422 statt eines
        DB-Fehlers. Zusaetzliche mandantenspezifische Custom-Fields werden dynamisch als
        weitere Top-Level-Schluessel akzeptiert (siehe GET /fields/inventory).
      properties:
        asset_number:
          type: string
          maxLength: 60
          description: Inventarnummer (Pflichtfeld bei Erstellung)
          example: "DMM-001"
        serial_number:
          type: string
          maxLength: 30
          example: "SN-1234567890"
        description:
          type: string
          maxLength: 60
          example: "Digital Multimeter"
        type_code:
          type: string
          maxLength: 60
        manufacturer:
          type: string
          maxLength: 30
          example: "FLUKE"
        model:
          type: string
          maxLength: 30
          example: "179"
        location:
          type: string
          maxLength: 12
          example: "Labor A"
        status:
          type: string
          maxLength: 100
          description: |
            Statuscode (I4211). Muss als JSON-String gesendet werden, auch wenn der Wert
            numerisch aussieht (z.B. "1"); ein unquotiertes JSON-Zahlenliteral scheitert
            an der `string`-Validierungsregel.
          example: "1"
        calibration_interval:
          type: integer
          description: Kalibrierintervall in Monaten
          example: 12
        customer_id:
          type: string
          maxLength: 36
          description: Kunden-UUID
      required:
        - asset_number

    # ── Field Configuration ───────────────────────
    FieldTableSummary:
      type: object
      description: Uebersicht einer Tabelle mit Feldkonfigurationen
      properties:
        table:
          type: string
          description: Tabellenname
          example: inventory
        field_count:
          type: integer
          description: Gesamtanzahl der Felder
          example: 65
        active_field_count:
          type: integer
          description: Anzahl aktiver Felder (edit_visible, edit_mandatory oder view_visible)
          example: 32

    FieldConfigurationResource:
      type: object
      properties:
        id:
          type: string
          description: "Zusammengesetzter Schluessel: `column_name:table_name`"
          example: "I4201:inventory"
        type:
          type: string
          enum: [field-configuration]
        attributes:
          $ref: '#/components/schemas/FieldConfigurationAttributes'

    FieldConfigurationAttributes:
      type: object
      properties:
        api_name:
          type: string
          description: Sprechender API-Feldname (z.B. `asset_number`)
          example: asset_number
        column_name:
          type: string
          description: Datenbank-Spaltenname (z.B. `I4201`)
          example: I4201
        table_name:
          type: string
          description: Tabellenname
          example: inventory
        label:
          type: string
          description: Menschenlesbares Label
          example: "Inventarnummer"
        field_type:
          type: string
          enum: [text, number, date, select, boolean]
          description: |
            Abgeleiteter Feldtyp fuer Grid/Formulare:
            - `text` — Textfelder (varchar, textarea, ...)
            - `number` — Zahlenfelder (int, float, decimal, ...)
            - `date` — Datumsfelder
            - `select` — Auswahlfelder (Dropdown, StatusDatabase, PickList, ...)
            - `boolean` — Ja/Nein-Felder
          example: text
        field_type_raw:
          type: string
          description: Originaler Feldtyp aus der Datenbank
          example: varchar
        default_value:
          type: string
          nullable: true
          description: Standardwert fuer neue Datensaetze
        filterable:
          type: boolean
          description: Im Grid filterbar
          example: true
        sortable:
          type: boolean
          description: Im Grid sortierbar
          example: true
        editable:
          type: boolean
          description: In Bearbeitungsformularen sichtbar
          example: true
        visible:
          type: boolean
          description: In der Detailansicht sichtbar
          example: true
        grid_default:
          type: boolean
          description: Standardmaessig im Grid angezeigt
          example: true
        mandatory:
          type: boolean
          description: Pflichtfeld in Formularen
          example: true
        display_order:
          type: integer
          description: Anzeigereihenfolge in Formularen
          example: 1
        display_order_grid:
          type: integer
          description: Anzeigereihenfolge im Grid
          example: 1
        width:
          type: integer
          nullable: true
          description: Spaltenbreite (null = automatisch)
        options:
          type: array
          nullable: true
          description: Auswahloptionen fuer Select-Felder
          items:
            type: object
            properties:
              value:
                type: string
              label:
                type: string
        field_category:
          type: string
          nullable: true
          description: |
            Feldkategorie (steuert Optionsquelle):
            `StatusDatabase`, `RequiredList`, `PickList`, `RequiredDatabase`, `PickDatabase`
        is_user_defined:
          type: boolean
          description: Benutzerdefiniertes Feld
        audit:
          type: boolean
          description: Im Audit-Trail erfasst
        allow_copy:
          type: boolean
          description: Beim Kopieren uebernommen
        security_message:
          type: boolean
          description: Sicherheitsmeldung anzeigen
        small_view_visible:
          type: boolean
          description: In der kompakten Ansicht sichtbar
        link_table_note:
          type: string
          nullable: true
          description: Verknuepfungstabelle fuer Notiz-Felder

    # ── Additional Field ──────────────────────────
    AdditionalFieldResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [additional-field]
        attributes:
          type: object
          properties:
            object_uID:
              type: string
              format: uuid
              description: UUID der Kategorie oder des Status
            override_type:
              type: string
              enum: [category, status]
              description: Typ des Overrides
            column_name:
              type: string
              description: Spaltenname des ueberschriebenen Feldes
              example: I4206
            default_value:
              type: string
              nullable: true
              description: Standardwert
            edit_visible:
              type: integer
              enum: [0, 1]
              description: Im Bearbeitungsformular sichtbar
            edit_mandatory:
              type: integer
              enum: [0, 1]
              description: Pflichtfeld im Bearbeitungsformular
            view_visible:
              type: integer
              enum: [0, 1]
              description: In der Detailansicht sichtbar
            display_order:
              type: integer
              description: Anzeigereihenfolge
            empty_now:
              type: integer
              enum: [0, 1]
              description: Feld bei bestehenden Datensaetzen leeren

    AdditionalFieldInput:
      type: object
      required: [object_uID, type, column_name]
      properties:
        object_uID:
          type: string
          format: uuid
          description: UUID der Kategorie oder des Status
        type:
          type: string
          enum: [category, status]
          description: Override-Typ
        column_name:
          type: string
          description: Spaltenname des zu ueberschreibenden Feldes
          example: I4206
        default_value:
          type: string
          nullable: true
        edit_visible:
          type: integer
          enum: [0, 1]
        edit_mandatory:
          type: integer
          enum: [0, 1]
        view_visible:
          type: integer
          enum: [0, 1]
        display_order:
          type: integer
        empty_now:
          type: integer
          enum: [0, 1]

    AdditionalFieldUpdate:
      type: object
      description: Partial-Update — nur uebergebene Felder werden geaendert
      properties:
        default_value:
          type: string
          nullable: true
        edit_visible:
          type: integer
          enum: [0, 1]
        edit_mandatory:
          type: integer
          enum: [0, 1]
        view_visible:
          type: integer
          enum: [0, 1]
        display_order:
          type: integer
        empty_now:
          type: integer
          enum: [0, 1]

    # ── Bulk operations (shared) ──────────────────
    BulkDeleteInput:
      type: object
      required: [ids]
      properties:
        ids:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: string
          description: Liste der zu loeschenden IDs

    BulkDeleteResultSchema:
      type: object
      properties:
        deleted:
          type: integer
          description: |
            Anzahl geloeschter Datensaetze. Bei manchen Ressourcen ungeprueft
            gleich der Anzahl angefragter IDs (siehe Endpunkt-Beschreibung).
        skipped:
          type: integer
          nullable: true
        skipped_ids:
          type: array
          nullable: true
          items:
            type: string

    # ── Device Type ────────────────────────────────
    DeviceTypeAttributes:
      type: object
      properties:
        type_name:
          type: string
          nullable: true
          example: "Fluke 179"
        manufacturer:
          type: string
          nullable: true
          example: "Fluke"
        model:
          type: string
          nullable: true
          example: "179"
        type_code:
          type: string
          nullable: true
          example: "DMM-01"
        type_version:
          type: string
          nullable: true
          example: "2.1"
        custom_fields:
          type: object
          additionalProperties: true
        created_by_label:
          type: string
          nullable: true
          example: "Max Mustermann"
        category_ids:
          type: array
          items:
            type: string
            format: uuid
        category_name:
          type: string
          nullable: true
          description: Kommaseparierte Kategorienamen
        is_blueprint:
          type: boolean
          description: Ob fuer diesen Typ eine (aktivierte) Blueprint-Vorbelegung existiert
      additionalProperties: true

    DeviceTypeResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [device-type]
        attributes:
          $ref: '#/components/schemas/DeviceTypeAttributes'

    DeviceTypeCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/DeviceTypeResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer

    DeviceTypeInput:
      type: object
      properties:
        type_name:
          type: string
          maxLength: 128
          description: Pflichtfeld bei Erstellung, eindeutig
          example: "Fluke 179"
        manufacturer:
          type: string
          maxLength: 30
        model:
          type: string
          maxLength: 30
        type_code:
          type: string
          maxLength: 60
        type_version:
          type: string
          maxLength: 256
        category_ids:
          type: array
          items:
            type: string
            format: uuid
          description: |
            Verknuepft den Typ direkt mit Inventar-Kategorien. Wenn dabei
            Pflichtfelder der Kategorie fehlen, antwortet der Endpunkt mit 422
            (`data.required_fields_missing`) statt zu speichern.

    DeviceTypeBlueprintAttributes:
      type: object
      properties:
        type_id:
          type: string
          format: uuid
        enabled:
          type: boolean
        copy_type_fields:
          type: boolean
        copy_categories:
          type: boolean
        field_defaults:
          type: object
          additionalProperties: true
          description: Vorbelegte Inventar-Feldwerte (api_name zu Wert)
        category_ids:
          type: array
          items:
            type: string
            format: uuid
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    DeviceTypeBlueprintResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          nullable: true
          description: "null, wenn noch kein Blueprint gespeichert wurde"
        type:
          type: string
          enum: [device-type-blueprint]
        attributes:
          $ref: '#/components/schemas/DeviceTypeBlueprintAttributes'

    DeviceTypeBlueprintInput:
      type: object
      properties:
        enabled:
          type: boolean
        copy_type_fields:
          type: boolean
        copy_categories:
          type: boolean
        field_defaults:
          type: object
          additionalProperties: true
        category_ids:
          type: array
          items:
            type: string

    # ── Status ─────────────────────────────────────
    StatusAttributes:
      type: object
      properties:
        title:
          type: string
          example: "Aktiv"
        short_title:
          type: string
          nullable: true
          example: "Akt."
        description:
          type: string
          example: "Geraet ist aktiv"
        color:
          type: string
          example: "#4CAF50"
        text_color:
          type: string
          example: "#FFFFFF"
        icon:
          type: string
          example: "check-circle"
        type:
          type: string
          enum: [inventory, calibration, booking, notepad, repair, location, support_tickets]
        sort_order:
          type: integer
        active:
          type: integer
          enum: [0, 1]
        hide:
          type: integer
          enum: [0, 1]

    StatusResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [status]
        attributes:
          $ref: '#/components/schemas/StatusAttributes'

    StatusInput:
      type: object
      required: [title, type]
      properties:
        title:
          type: string
          maxLength: 100
        short_title:
          type: string
          maxLength: 50
        description:
          type: string
          maxLength: 255
        color:
          type: string
          maxLength: 50
        text_color:
          type: string
          maxLength: 50
        icon:
          type: string
          maxLength: 255
        type:
          type: string
          enum: [inventory, calibration, booking, notepad, repair, location, support_tickets]
        sort_order:
          type: integer
          description: Standard = aktuelles Maximum je Typ + 1

    StatusUpdate:
      type: object
      description: "Partial-Update. `type` ist nicht aenderbar."
      properties:
        title:
          type: string
          maxLength: 100
        short_title:
          type: string
          maxLength: 50
        description:
          type: string
          maxLength: 255
        color:
          type: string
          maxLength: 50
        text_color:
          type: string
          maxLength: 50
        icon:
          type: string
          maxLength: 255
        sort_order:
          type: integer
        active:
          type: integer
          enum: [0, 1]
        hide:
          type: integer
          enum: [0, 1]

    MapStatusInput:
      type: object
      properties:
        status_id:
          type: string
          maxLength: 36
          description: "Status-UUID (kein FK-Constraint in der DB)"
        target_type:
          type: string
          maxLength: 255
          example: "calibration_interval"
        target:
          type: string
          maxLength: 255
          example: "12"

    # ── Test Standard ──────────────────────────────
    TestStandardAttributes:
      type: object
      properties:
        name:
          type: string
          example: "DIN EN ISO 6789-1"
        section:
          type: string
          nullable: true
          example: "6.2"
        remark:
          type: string
          nullable: true
          example: "Drehmomentschluessel"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        inventories_count:
          type: integer
          description: Nur vorhanden, wenn die Anzahl mitgezaehlt wurde

    TestStandardResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [test-standard]
        attributes:
          $ref: '#/components/schemas/TestStandardAttributes'

    TestStandardCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/TestStandardResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer

    TestStandardInput:
      type: object
      properties:
        name:
          type: string
          maxLength: 255
          description: "Pflichtfeld bei Erstellung. Zusammen mit `section` eindeutig."
        section:
          type: string
          maxLength: 255
          nullable: true
        remark:
          type: string
          maxLength: 4000
          nullable: true

    # ── Audit ──────────────────────────────────────
    AuditAttributes:
      type: object
      properties:
        timestamp:
          type: integer
          description: Unix-Zeitstempel
        user_id:
          type: string
          format: uuid
          nullable: true
        type:
          type: string
          nullable: true
          description: "V1-Aktionstyp, z.B. `change_inventory_field` (nicht der JSON:API `type`)"
        field_name:
          type: string
          nullable: true
        title:
          type: string
          nullable: true
        data_old:
          type: string
          nullable: true
        data_new:
          type: string
          nullable: true
        table:
          type: string
          nullable: true
        dataset_id:
          type: string
          nullable: true
        user_label:
          type: string
          nullable: true
        dataset_label:
          type: string
          nullable: true
        field_label:
          type: string
          nullable: true
        data_old_label:
          type: string
          nullable: true
        data_new_label:
          type: string
          nullable: true

    AuditResource:
      type: object
      properties:
        id:
          type: string
        type:
          type: string
          enum: [audit]
        attributes:
          $ref: '#/components/schemas/AuditAttributes'

    AuditCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/AuditResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer

    # ── Utility: Lookup / Search ───────────────────
    LookupOptionAttributes:
      type: object
      properties:
        label:
          type: string
          example: "DMM-001 - SN-1234567890 - FLUKE - 179"
        columns:
          type: object
          additionalProperties: true
          description: Konfigurierte Anzeigefelder als api_name zu Wert

    LookupOptionResource:
      type: object
      properties:
        id:
          type: string
        type:
          type: string
          enum: [lookup-option]
        attributes:
          $ref: '#/components/schemas/LookupOptionAttributes'

    # ── Field Configurations (Legacy V1-Tabellen, /field-configurations) ──
    FieldConfigEntryAttributes:
      type: object
      description: |
        Feldmetadaten aus der geteilten V1/V2-Tabelle `field_configuration`.
        Nicht zu verwechseln mit `FieldConfigurationAttributes` (Endpunkt `/fields/{table}`)
        oder `FieldDefinitionAttributes` (V2-native Registry, `/field-definitions`).
      properties:
        api_name:
          type: string
        api_name_raw:
          type: string
          nullable: true
        column_name:
          type: string
        table_name:
          type: string
        label:
          type: string
        description:
          type: string
        field_type:
          type: string
          enum: [text, number, date, select, boolean, image, tree]
        field_type_raw:
          type: string
        default_value:
          type: string
          nullable: true
        filterable:
          type: boolean
        sortable:
          type: boolean
        editable:
          type: boolean
        visible:
          type: boolean
        grid_default:
          type: boolean
        mandatory:
          type: boolean
        display_order:
          type: integer
        display_order_grid:
          type: integer
        width:
          type: integer
          nullable: true
        options:
          type: array
          nullable: true
          items:
            type: object
            properties:
              value:
                type: string
              label:
                type: string
        field_category:
          type: string
        is_user_defined:
          type: boolean
        audit:
          type: boolean
        allow_copy:
          type: boolean
        security_message:
          type: boolean
        small_view_visible:
          type: boolean
        link_table_note:
          type: string
          nullable: true

    FieldConfigEntryResource:
      type: object
      properties:
        id:
          type: string
          description: "`{column_name}:{table_name}`"
        type:
          type: string
          enum: [field-configuration]
        attributes:
          $ref: '#/components/schemas/FieldConfigEntryAttributes'

    # ── Field Definitions (V2-native Registry, /field-definitions) ────
    FieldDefinitionAttributes:
      type: object
      properties:
        api_name:
          type: string
          example: serial_number
        column_name:
          type: string
          example: I4206
        storage:
          type: string
          enum: [column, json, virtual]
        v1_name:
          type: string
          nullable: true
        is_protected:
          type: boolean
        deletable:
          type: boolean
        active:
          type: boolean
        table_name:
          type: string
        label:
          type: string
        description:
          type: string
        field_type:
          type: string
          enum: [text, number, date, select, boolean, image, tree, document]
        field_type_raw:
          type: string
        max_length:
          type: integer
          nullable: true
        default_value:
          type: string
          nullable: true
        default_value_depends_on:
          type: array
          items:
            type: string
        filterable:
          type: boolean
        sortable:
          type: boolean
        editable:
          type: boolean
        visible:
          type: boolean
        grid_default:
          type: boolean
        mandatory:
          type: boolean
        edit_visible:
          type: boolean
        view_visible:
          type: boolean
        edit_mandatory:
          type: boolean
        grid_available:
          type: boolean
        grid_show_default:
          type: boolean
        display_order:
          type: integer
        display_order_grid:
          type: integer
        width:
          type: integer
          nullable: true
        options:
          type: array
          nullable: true
          items:
            type: object
            properties:
              value:
                type: string
              label:
                type: string
        field_options:
          type: string
          nullable: true
        field_category:
          type: string
        is_user_defined:
          type: boolean
        audit:
          type: boolean
        allow_copy:
          type: boolean
        security_message:
          type: boolean
        small_view_visible:
          type: boolean
        link_table_note:
          type: string
          nullable: true
        aggregations:
          type: string
          nullable: true
        related_resource:
          type: string
          nullable: true
        related_display_field:
          type: string
          nullable: true

    FieldDefinitionResource:
      type: object
      properties:
        id:
          type: string
          description: "`{api_name}:{table_name}`"
        type:
          type: string
          enum: [field-definition]
        attributes:
          $ref: '#/components/schemas/FieldDefinitionAttributes'

    # ── Shared cross-domain schemas ────────────────
    # Signatures, Documents (DMS), Report-Settings/-Parameters and Mailer-Messages
    # are returned identically-shaped from many resources (Calibration, Repair,
    # Booking, Location, Inventory, Ticket, Notepad). Defined ONCE here — do not
    # redefine per resource, just $ref these.
    SignatureAttributes:
      type: object
      properties:
        setting_id:
          type: string
          description: UUID der Signatur-Einstellung (`SignatureSetting.uID`)
        user_id:
          type: string
          format: uuid
          nullable: true
          description: Unterzeichnender Benutzer
        value:
          type: string
          description: Freies Signatur-Payload (z.B. Stroke-JSON oder Base64-Bild)
          example: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
        object_id:
          type: string
          nullable: true
          description: Optionale Objekt-Referenz, z.B. eine Report-Setting-UUID
        type:
          type: string
          nullable: true
          enum: [report, dms]
        link_table:
          type: string
          description: "Entitaetstabelle, an die die Signatur haengt, z.B. `calibration`, `repair`, `dms`"
          example: calibration
        link_id:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    SignatureResource:
      type: object
      properties:
        id:
          type: string
          description: UUID der Signatur
        type:
          type: string
          enum: [signature]
        attributes:
          $ref: '#/components/schemas/SignatureAttributes'

    DocumentAttributes:
      type: object
      properties:
        name:
          type: string
          nullable: true
          example: "Kalibrierschein_DMM-001"
        hash:
          type: string
          nullable: true
          description: Gruppiert Versionen desselben Dokuments
        major_version:
          type: integer
        minor_version:
          type: integer
        version:
          type: string
          description: "`{major_version}.{minor_version}`"
          example: "1.0"
        is_latest:
          type: boolean
        is_existed:
          type: boolean
          description: Ob die physische Datei vorhanden ist
        filename:
          type: string
          nullable: true
        filetype:
          type: string
          nullable: true
          example: pdf
        type:
          type: string
          nullable: true
          description: "Dokumentart (`file_setting.column_name`), z.B. `certificate`, `inventory_doc`"
        created:
          type: integer
          description: Unix-Zeitstempel (kein ISO-Datum)
        created_by:
          type: string
          format: uuid
          nullable: true
        link_table:
          type: string
          nullable: true
          enum: [inventory, calibration, booking, repair, location, types, wiki, specification, master_report_setting, master_report_template, ticket, ticket_comments, notepad, procedure_templates]
        link_id:
          type: string
          nullable: true
        origin_label:
          type: string
          nullable: true
          description: |
            Lesbare Herkunft des Dokuments — der Datensatz hinter
            `link_table`/`link_id`. Nur gefuellt, wo die Antwort Herkuenfte
            mischt (`GET /inventories/{mtag}/documents?include_related=1`);
            sonst `null`.
          example: "Kalibrierung Z-4711, 04.06.2026"
        folder_name:
          type: string
          nullable: true
          description: |
            Eingangsordner des DMS-Fachs, in dem das Dokument liegt
            (`file_setting.folder_name`) — die Herkunft der Datei im
            Ordner-Sinn, waehrend `origin_label` den Datensatz nennt.

            `null` bei einem Dokument ohne Fach (Berichtsdatei, PDF-Vorlage,
            Pruefplan-Anlage) und in Antworten, die das Fach nicht mitladen —
            die Entitaetslisten (`/{resource}/{id}/documents`) tun es.
          example: "zertifikate"
        report_id:
          type: string
          nullable: true
        status:
          type: integer
          enum: [0, 1]
          description: "0 = nicht freigegeben, 1 = freigegeben"
        action_title:
          type: string
          nullable: true
        released_first:
          type: integer
          nullable: true

    DocumentResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [document]
        attributes:
          $ref: '#/components/schemas/DocumentAttributes'
        relationships:
          type: object
          properties:
            creator:
              type: object
              properties:
                data:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: string
                    type:
                      type: string
                      enum: [user]
                    name:
                      type: string
            approver:
              type: object
              properties:
                data:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: string
                    type:
                      type: string
                      enum: [user]
                    name:
                      type: string

    ReportSettingAttributes:
      type: object
      properties:
        button_name:
          type: string
          example: "Kalibrierbericht"
        menu:
          type: string
          nullable: true
        format:
          type: string
          example: pdf
        folder_name:
          type: string
        report_name:
          type: string
          example: "calibration_report.jrxml"
        is_label:
          type: boolean
        is_print:
          type: boolean
        email_attachment:
          type: boolean

    ReportSettingResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [report-setting]
        attributes:
          $ref: '#/components/schemas/ReportSettingAttributes'

    ReportParameterAttributes:
      type: object
      properties:
        name:
          type: string
          example: Start_Date
        type:
          type: string
          description: Vereinfachter Typ, abgeleitet aus der JRXML-Java-Klasse
          example: Date
        default:
          type: string
          nullable: true

    ReportParameterResource:
      type: object
      properties:
        id:
          type: string
        type:
          type: string
          enum: [report-parameter]
        attributes:
          $ref: '#/components/schemas/ReportParameterAttributes'

    MailerMessageAttributes:
      type: object
      properties:
        status:
          type: string
          enum: [sent, failed]
        error_message:
          type: string
          nullable: true
        to_address:
          type: string
          format: email
        subject:
          type: string

    MailerMessageResource:
      type: object
      properties:
        id:
          type: string
        type:
          type: string
          enum: [mailer-message]
        attributes:
          $ref: '#/components/schemas/MailerMessageAttributes'

    DocumentCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/DocumentResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
            api_version:
              type: string
    # ── Calibration ────────────────────────────────
    CalibrationAttributes:
      type: object
      description: |
        Kalibrier-Attribute mit sprechenden Feldnamen. Installationsspezifische
        dynamische Felder (`custom_fields`, ueber `GET /api/v2/fields/calibrations`
        abrufbar, ca. 80 V1-Parity-Felder) werden direkt als einzelne
        Top-Level-Schluessel eingemischt (z.B. `C2302`), nicht unter einem
        eigenen `custom_fields`-Objekt verschachtelt.
      properties:
        inventory_id:
          type: string
          format: uuid
          description: MTAG des kalibrierten Inventarobjekts (Pflichtfeld)
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        customer_id:
          type: string
          format: uuid
          nullable: true
          example: "45f09720-082f-5c74-9cfa-a605e2863904"
        calibration_date:
          type: string
          format: date
          nullable: true
          example: "2025-06-15"
        next_calibration_date:
          type: string
          format: date
          nullable: true
          example: "2025-12-15"
        callsheet_number:
          type: string
          nullable: true
          maxLength: 50
          description: >-
            Scheinnummer des MET/TEAM-CallSheets (`CallSheets.cCallSheetNumber`).
            Text, nicht Zahl — Fluke vergibt sie im Regelfall als
            `<Auftrag>-<Position>`. Hiess bis calServer 6.0 `interval_override`
            und war faelschlich als Integer gefuehrt; die Umbenennung greift
            nur dort, wo der Feldname noch die Werksvorgabe war.
          example: "282-123"
        certificate_number:
          type: string
          nullable: true
          maxLength: 50
          description: Eindeutig (unique index) — bereits vergebene Nummern fuehren zu 422/409.
          example: "CERT-48213"
        order_number:
          type: string
          nullable: true
          maxLength: 100
          example: "AUF-30271"
        technician:
          type: string
          nullable: true
          maxLength: 36
          example: "Techniker 1"
        temperature:
          type: number
          format: float
          nullable: true
          example: 22.5
        humidity:
          type: number
          format: float
          nullable: true
          example: 45.5
        is_active:
          type: integer
          nullable: true
          description: |
            Standard 1. Wird eine Kalibrierung mit `is_active=1` gespeichert,
            werden alle anderen Kalibrierungen desselben Inventarobjekts
            automatisch deaktiviert (nur eine aktive Kalibrierung je Objekt).
          example: 1
        calibration_time:
          type: string
          nullable: true
          default: "00:00:00"
          example: "09:00:00"
        language_code:
          type: string
          default: "D"
          example: "D"
        data_source:
          type: string
          nullable: true
          example: "cal"
        status:
          type: string
          nullable: true
          maxLength: 100
          description: UUID (uID) aus der `status`-Tabelle mit `type=calibration`.
          example: "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456"
        cal_result:
          type: string
          nullable: true
          maxLength: 1
          enum: ["Y", "N", "O"]
          description: "Y = bestanden, N = nicht bestanden, O = ohne Pruefentscheid"
          example: "Y"
        external_callsheet_id:
          type: string
          nullable: true
          maxLength: 36
          description: Korrelations-ID fuer MET/TEAM-Import
        inventory_id_label:
          type: string
          nullable: true
          description: Anzeigename des Inventarobjekts (inventory.asset_number)
          example: "INV-0001"
        customer_id_label:
          type: string
          nullable: true
          example: "Test GmbH"
        status_label:
          type: string
          nullable: true
          example: "Abgeschlossen (MET/TEAM)"
        certificate:
          type: object
          nullable: true
          description: Virtuelles DMS-Dokumentlink-Feld (Kategorie `certificate`).
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            filetype:
              type: string
            count:
              type: integer
          example:
            id: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
            name: "CERT-48213.pdf"
            filetype: pdf
            count: 1
        category_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Zugeordnete Kategorie-UUIDs
      additionalProperties: true

    CalibrationResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: CTAG (UUID) der Kalibrierung
        type:
          type: string
          enum: [calibration]
        attributes:
          $ref: '#/components/schemas/CalibrationAttributes'
        relationships:
          type: object
          description: |
            Einziger moeglicher Schluessel ist `inventory`. Nur bei
            `GET /calibrations/{ctag}` und `GET /calibrations/due` eager-geladen
            (`{"data":{"id","type":"inventory"}}`) — bei `POST`/`PUT`/`duplicate`/
            `bulk` degradiert der Wert stattdessen zu einem leeren Array `[]`
            (nicht `{"data":null}`).
          properties:
            inventory:
              description: "`{\"data\":{...}}` wenn geladen, sonst `[]`."
              oneOf:
                - type: object
                  properties:
                    data:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        type:
                          type: string
                          enum: [inventory]
                - type: array
                  maxItems: 0
                  items: {}

    CalibrationCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/CalibrationResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
            api_version:
              type: string
        links:
          type: object
          properties:
            self:
              type: string
            first:
              type: string
            last:
              type: string
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    CalibrationInput:
      type: object
      required: [inventory_id]
      description: |
        Weitere installationsspezifische Felder (siehe `custom_fields`-Hinweis
        bei `CalibrationAttributes`) koennen als zusaetzliche Top-Level-
        Properties uebergeben werden.
      properties:
        inventory_id:
          type: string
          maxLength: 36
          description: MTAG des zu kalibrierenden Inventarobjekts (Pflichtfeld)
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        calibration_date:
          type: string
          maxLength: 255
          nullable: true
          example: "2025-06-15"
        next_calibration_date:
          type: string
          maxLength: 255
          nullable: true
          example: "2025-12-15"
        certificate_number:
          type: string
          maxLength: 50
          nullable: true
          description: Muss eindeutig sein — siehe 422/409 bei Duplikat in den Endpunkt-Beschreibungen.
          example: "CERT-48213"
        technician:
          type: string
          maxLength: 36
          nullable: true
          example: "Techniker 1"
        is_active:
          type: integer
          nullable: true
          example: 1
        customer_id:
          type: string
          maxLength: 36
          nullable: true
          example: "45f09720-082f-5c74-9cfa-a605e2863904"
        status:
          type: string
          maxLength: 100
          nullable: true
          example: "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456"
        cal_result:
          type: string
          maxLength: 1
          nullable: true
          enum: ["Y", "N", "O"]
          example: "Y"
        category_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Kategorie-Zuordnung (keine eigene Spalte).
          example: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
      additionalProperties: true

    CalibrationUpdateInput:
      type: object
      description: |
        Partial Update — nur uebergebene Felder werden geaendert. Kein
        `inventory_id` (nicht aenderbar). Weicht `status` vom aktuellen Wert
        ab, laeuft das Update durch die Status-Transition (Pflichtfeld-
        Pruefung, Defaults, Zaehler) — die Antwort bekommt dann zusaetzlich
        `meta.transition`.
      properties:
        calibration_date:
          type: string
          maxLength: 255
          nullable: true
        next_calibration_date:
          type: string
          maxLength: 255
          nullable: true
        certificate_number:
          type: string
          maxLength: 50
          nullable: true
        technician:
          type: string
          maxLength: 36
          nullable: true
        is_active:
          type: integer
          nullable: true
        customer_id:
          type: string
          maxLength: 36
          nullable: true
        status:
          type: string
          maxLength: 100
          nullable: true
          example: "f2d4e6a8-1b3c-4d5e-8f90-abcdef123456"
        cal_result:
          type: string
          maxLength: 1
          nullable: true
          enum: ["Y", "N", "O"]
        category_ids:
          type: array
          items:
            type: string
            format: uuid
      additionalProperties: true

    # ── Results ────────────────────────────────────
    ResultAttributes:
      type: object
      description: |
        Kernfelder mit sprechenden Namen. Die restlichen (~250) Rohspalten der
        `results`-Tabelle werden unveraendert durchgereicht (siehe
        `additionalProperties`).
      properties:
        row_number:
          type: integer
          example: 1
        calibration_id:
          type: string
          format: uuid
        inventory_id:
          type: string
          format: uuid
          nullable: true
        test_description:
          type: string
          maxLength: 254
          nullable: true
          example: "Test Point 1"
        fixed_quantity:
          type: string
          maxLength: 32
          nullable: true
          example: "10"
        variable_quantity:
          type: string
          maxLength: 32
          nullable: true
          example: "10.1"
        lower_limit:
          type: string
          maxLength: 32
          nullable: true
          example: "9.9"
        upper_limit:
          type: string
          maxLength: 32
          nullable: true
          example: "10.1"
        pass_fail:
          type: string
          maxLength: 50
          nullable: true
          example: "Pass"
        expanded_uncertainty:
          type: string
          maxLength: 32
          nullable: true
          example: "0.5"
        standard_uncertainty:
          type: string
          maxLength: 32
          nullable: true
          example: "0.1"
        coverage_factor:
          type: string
          maxLength: 32
          nullable: true
          example: "2"
        tolerance_reference:
          type: string
          maxLength: 32
          nullable: true
          example: "Nominal"
        tolerance_positive:
          type: string
          maxLength: 32
          nullable: true
          example: "0.2"
        tolerance_negative:
          type: string
          maxLength: 32
          nullable: true
          example: "-0.2"
        standard_deviation:
          type: string
          maxLength: 32
          nullable: true
          example: "0.1"
        comment:
          type: string
          nullable: true
        images:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true
      additionalProperties: true

    ResultResource:
      type: object
      properties:
        id:
          type: string
          description: "Synthetischer Schluessel `{row_num}:{calibration_id}`"
          example: "1:9c4b6e2a-1234-4a1b-8c3d-abcdef123456"
        type:
          type: string
          enum: [result]
        attributes:
          $ref: '#/components/schemas/ResultAttributes'

    # ── Standards (Referenznormale) ────────────────
    StandardAttributes:
      type: object
      properties:
        standard_id:
          type: string
          format: uuid
          description: UUID des Referenznormals (C2430)
          example: "7c1a2b3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
        calibration_id:
          type: string
          format: uuid
          description: CTAG der Kalibrierung
        inventory_id:
          type: string
          format: uuid
          description: MTAG des kalibrierten Inventarobjekts (nicht das Referenznormal selbst)
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        standard_equipment:
          type: object
          nullable: true
          description: |
            Nur bei `GET /calibrations/{ctag}/standards` eager-geladen — bei
            `POST`/`PUT`-Antworten immer `null`.
          properties:
            id:
              type: string
              format: uuid
            asset_number:
              type: string
            serial_number:
              type: string
              nullable: true
            manufacturer:
              type: string
              nullable: true
            model:
              type: string
              nullable: true
            description:
              type: string
              nullable: true
            last_calibration:
              type: object
              nullable: true
              properties:
                id:
                  type: string
                  format: uuid
                calibration_date:
                  type: string
                  format: date
                  nullable: true
                next_calibration_date:
                  type: string
                  format: date
                  nullable: true
                certificate_number:
                  type: string
                  nullable: true
          example:
            id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
            asset_number: "REF-001"
            serial_number: "SN-REF-001"
            manufacturer: "Fluke"
            model: "5522A"
            description: null
            last_calibration:
              id: "d1e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f"
              calibration_date: "2025-06-01"
              next_calibration_date: "2026-06-01"
              certificate_number: "REF-CERT-42"

    StandardResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: "= standard_id"
        type:
          type: string
          enum: [standard]
        attributes:
          $ref: '#/components/schemas/StandardAttributes'

    # ── Procedure Measurements (nur lesend) ────────
    ProcedureMeasurementAttributes:
      type: object
      properties:
        calibration_id:
          type: string
          format: uuid
        test_step_id:
          type: string
          format: uuid
          nullable: true
          example: "7c1a2b3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
        procedure_run_id:
          type: string
          format: uuid
          nullable: true
        row_num:
          type: integer
          example: 1
        test_step_no:
          type: string
          nullable: true
          example: "1.001"
        kind:
          type: string
          enum: [numeric, manual, rating, entry]
        source:
          type: string
          enum: [procedure, manual_entry, metteam]
        tol_ref:
          type: string
          nullable: true
          example: "Nominal"
        test_status:
          type: string
          nullable: true
          example: "Pass"
        rating:
          type: string
          nullable: true
        measured_value:
          type: string
          nullable: true
          example: "10.0"
        measured_prefix:
          type: string
          nullable: true
        measured_unit:
          type: string
          nullable: true
          example: "V"
        measured_base:
          type: string
          nullable: true
          example: "10.0"
        nominal_base:
          type: string
          nullable: true
          example: "10.0"
        lower_limit_base:
          type: string
          nullable: true
          example: "9.9"
        upper_limit_base:
          type: string
          nullable: true
          example: "10.1"
        error_base:
          type: string
          nullable: true
          example: "0.0"
        tol_err:
          type: string
          nullable: true
          example: "0.0"
        uncertainty:
          type: string
          nullable: true
          example: "0.05"
        engine_version:
          type: string
          nullable: true
          example: "v1-legacy"
        captured_by:
          type: string
          format: uuid
          nullable: true
        captured_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ProcedureMeasurementResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [procedure-measurement]
        attributes:
          $ref: '#/components/schemas/ProcedureMeasurementAttributes'
    # ── Procedure ──────────────────────────────────
    ProcedureAttributes:
      type: object
      properties:
        procedure_name:
          type: string
          description: Bezeichnung der Pruefprozedur
          example: "Drehmomentschluessel — Vergleichskalibrierung"
        calibration_item:
          type: string
          nullable: true
          example: "Digital Multimeter"
        calibration_method:
          type: string
          nullable: true
          example: "Vergleich"
        procedure_description:
          type: string
          nullable: true
          example: "Pruefung nach Werksstandard."
        measurement_conditions:
          type: string
          nullable: true
          example: "23 °C, 50 % r.F."
        standards:
          type: string
          nullable: true
          description: Freitext-Referenz auf Pruefnormen (kein FK auf `/test-standards`)
          example: "DIN EN ISO 6789"
        scope:
          type: string
          nullable: true
          example: "Werkslabor"
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    ProcedureResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: uID der Prozedur
        type:
          type: string
          enum: [procedure]
        attributes:
          $ref: '#/components/schemas/ProcedureAttributes'

    ProcedureCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ProcedureResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
        links:
          type: object
          properties:
            self:
              type: string
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    ProcedureInput:
      type: object
      required: [procedure_name]
      properties:
        procedure_name:
          type: string
          maxLength: 255
          description: Pflichtfeld
          example: "Drehmomentschluessel — Vergleich"
        calibration_item:
          type: string
          maxLength: 2048
          nullable: true
          example: "Drehmomentschluessel"
        calibration_method:
          type: string
          maxLength: 2048
          nullable: true
          example: "Vergleich"
        procedure_description:
          type: string
          maxLength: 2048
          nullable: true
        measurement_conditions:
          type: string
          maxLength: 2048
          nullable: true
          example: "23 °C, 50 % r.F."
        standards:
          type: string
          maxLength: 500
          nullable: true
          example: "DIN EN ISO 6789"
        scope:
          type: string
          maxLength: 500
          nullable: true
          example: "Werkslabor"

    ProcedureUpdateInput:
      type: object
      description: Partial-Update — alle Felder optional, sonst identisch zu `ProcedureInput`.
      properties:
        procedure_name:
          type: string
          maxLength: 255
        calibration_item:
          type: string
          maxLength: 2048
          nullable: true
        calibration_method:
          type: string
          maxLength: 2048
          nullable: true
        procedure_description:
          type: string
          maxLength: 2048
          nullable: true
        measurement_conditions:
          type: string
          maxLength: 2048
          nullable: true
        standards:
          type: string
          maxLength: 500
          nullable: true
        scope:
          type: string
          maxLength: 500
          nullable: true

    # ── Procedure Template ─────────────────────────
    ProcedureTemplateAttributes:
      type: object
      properties:
        procedure_id:
          type: string
          format: uuid
          nullable: true
          description: Verknuepfte Prozedur (gesetzt bei `link_table="procedures"`)
        name:
          type: string
          nullable: true
          example: "DMM Kalibrierverfahren"
        file_name:
          type: string
          description: Gespeicherter Storage-Dateiname (nicht der urspruengliche Upload-Name)
          example: "a1b2c3d4e5f6g7h8i9j0.pdf"
        file_type:
          type: string
          description: "`pdf`, `json`, `pxe` oder `xlsx`"
          example: pdf
        kind:
          type: string
          readOnly: true
          enum: [internal, metcal, excel, document]
          description: Aus `file_type` abgeleitet — nur `internal` (JSON) hat einen ladbaren Pruefablauf.
        kind_label:
          type: string
          readOnly: true
          example: "Eigene Messwertvorlage"
        hash:
          type: string
          description: Gruppiert Versionen desselben Dokuments (20 Zeichen)
          example: "a1b2c3d4e5f6g7h8i9j0"
        major_version:
          type: integer
          example: 0
        minor_version:
          type: integer
          example: 1
        is_latest:
          type: boolean
        is_existed:
          type: boolean
          description: Ob die physische Datei noch auf dem Storage vorhanden ist
        approved_by:
          type: string
          format: uuid
          nullable: true
        file_size:
          type: integer
          nullable: true
          example: 245678
        released:
          type: string
          nullable: true
          enum: [approval, release]
          description: "null = Entwurf. `/approve` setzt `approval`, `/release` setzt `release`."
        link_table:
          type: string
          nullable: true
          example: procedures
        link_id:
          type: string
          nullable: true
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    ProcedureTemplateResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [procedure-template]
        attributes:
          $ref: '#/components/schemas/ProcedureTemplateAttributes'

    # ── Specification (ADR 2026-11-02 „Eine Spezifikation mit Optionen“) ──
    SpecificationHead:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: "Pt100-Widerstandsthermometer"
        device_type_id:
          type: string
          nullable: true
          description: Geraetetyp (tID), wenn die Spezifikation am Typ haengt
        inventory_id:
          type: string
          nullable: true
          description: Geraet (MTAG), wenn es eine Geraetespezifikation ist
        scope:
          type: string
          enum: [type, device]
        current_revision_id:
          type: string
          nullable: true
        draft_revision_id:
          type: string
          nullable: true
        current_revision:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationRevisionSummary'
          nullable: true
        draft_revision:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationRevisionSummary'
          nullable: true
        created_by:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    SpecificationListItem:
      allOf:
        - $ref: '#/components/schemas/SpecificationHead'
        - type: object
          properties:
            options_count:
              type: integer
            rows_count:
              type: integer

    SpecificationDetail:
      allOf:
        - $ref: '#/components/schemas/SpecificationHead'
        - type: object
          properties:
            revisions:
              type: array
              items:
                $ref: '#/components/schemas/SpecificationRevisionSummary'
            current:
              type: object
              allOf:
                - $ref: '#/components/schemas/SpecificationRevisionContent'
              nullable: true
            draft:
              type: object
              allOf:
                - $ref: '#/components/schemas/SpecificationRevisionContent'
              nullable: true
            draft_issues:
              type: array
              items:
                $ref: '#/components/schemas/SpecificationIssue'

    SpecificationRevisionSummary:
      type: object
      properties:
        id:
          type: string
          format: uuid
        number:
          type: integer
          example: 2
        status:
          type: string
          enum: [draft, released, withdrawn]
        name:
          type: string
        source:
          type: string
          enum: [manual, import, extracted, metcal, acc]
        default_option_key:
          type: string
          nullable: true
        released_at:
          type: string
          format: date-time
          nullable: true
        released_by:
          type: string
          nullable: true
        withdrawn_at:
          type: string
          format: date-time
          nullable: true
        content_hash:
          type: string
          nullable: true
          description: SHA-256 des Inhalts, gesetzt mit der Freigabe

    SpecificationRevision:
      allOf:
        - $ref: '#/components/schemas/SpecificationRevisionSummary'
        - type: object
          properties:
            specification_id:
              type: string
              format: uuid
            source_reference:
              type: string
              nullable: true
            change_note:
              type: string
              nullable: true
            created_by:
              type: string
              nullable: true
            edited_by:
              type: string
              nullable: true
            withdrawn_by:
              type: string
              nullable: true
            withdraw_reason:
              type: string
              nullable: true
            created_at:
              type: string
              format: date-time
              nullable: true
            updated_at:
              type: string
              format: date-time
              nullable: true

    SpecificationRevisionContent:
      allOf:
        - $ref: '#/components/schemas/SpecificationRevision'
        - type: object
          properties:
            options:
              type: array
              items:
                allOf:
                  - type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                  - $ref: '#/components/schemas/SpecificationOption'
            rows:
              type: array
              items:
                allOf:
                  - type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                  - $ref: '#/components/schemas/SpecificationRow'

    SpecificationOption:
      type: object
      required: [key]
      properties:
        key:
          type: string
          maxLength: 64
          description: Bleibt ueber Revisionen gleich; Kalibrierungen und Geraete verweisen darauf.
          example: class-a
        name:
          type: string
          example: "Klasse A"
        kind:
          type: string
          enum: [manufacturer, standard, customer, usage]
        customer_id:
          type: string
          nullable: true
          description: Nur bei `kind = customer`; die Option erscheint nur fuer Kalibrierungen dieses Kunden.
        interval_value:
          type: integer
          nullable: true
        interval_unit:
          type: string
          nullable: true
          description: "`D`, `W`, `M` oder `Y`"
        confidence:
          type: number
          nullable: true
          description: Erweiterungsfaktor k der Angaben in dieser Option
        note:
          type: string
          nullable: true
          description: Etwa der Abschnitt der Norm

    SpecificationRow:
      type: object
      required: [kind]
      properties:
        key:
          type: string
          nullable: true
          description: Stabiler Zeilenschluessel; Vergleich und Zusammenfuehren richten sich danach.
        kind:
          type: string
          enum: [band, point, derived, contribution, header, comment]
          description: |
            `band` gibt eine Toleranz fuer alle Werte zwischen Anfang und Ende;
            `point` verlangt genau diesen Pruefpunkt in jeder Option, die ihn
            nutzt; `derived` ist die Grenze einer abgeleiteten Groesse
            (`derived = hysteresis`); `contribution` ist ein Beitrag zur
            Messunsicherheit als Halbbreite einer Rechteckverteilung.
        description:
          type: string
          nullable: true
        function:
          type: string
          nullable: true
          description: Messfunktion aus dem Katalog, etwa `dcv` oder `temp`
        derived:
          type: string
          nullable: true
          enum: [hysteresis, null]
        prefix:
          type: string
          nullable: true
        unit:
          type: string
          nullable: true
        range:
          type: number
          nullable: true
        value:
          type: number
          nullable: true
        range_start:
          type: number
          nullable: true
        range_end:
          type: number
          nullable: true
        modifier_start:
          type: number
          nullable: true
        modifier_end:
          type: number
          nullable: true
        modifier_unit:
          type: string
          nullable: true
        resolution:
          type: number
          nullable: true
        symmetric:
          type: boolean
          description: Das Band deckt auch den gespiegelten Bereich (Betrag)
        source_reference:
          type: string
          nullable: true
          maxLength: 255
          description: |
            Fundstelle der Zeile, etwa die Seite des Datenblatts; beim
            MET/CAL-Import FSC, Schritt und Rolle (`5500 1.003 · UUT Indicated`).
            Nur zur Nachverfolgung, nie Teil einer Rechnung.
        tolerances:
          type: object
          description: Optionsschluessel zu Toleranz in MET/CAL-Schreibweise; fehlt der Schluessel, nutzt die Option die Zeile nicht.
          additionalProperties:
            type: string
          example:
            class-a: "0.15U 0.2%"
            class-b: "0.3U 0.5%"

    MetCalSpecificationResult:
      type: object
      properties:
        data:
          type: object
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            type_id:
              type: string
            option_key:
              type: string
              example: metcal-fluke-87v-dcv
            revision:
              type: integer
              description: Nummer der Entwurfsrevision, in der die Option steht
            source:
              type: string
              nullable: true
            source_reference:
              type: string
              nullable: true
            is_released:
              type: boolean
              description: Immer false; freigegeben wird von einer Person.
            rows_count:
              type: integer
              description: Zahl der uebernommenen Zeilen ohne Ueberschriften
            action:
              type: string
              enum: [created, option_added, replaced, new_version]
              description: |
                `created` neue Spezifikation, `option_added` Option neu im
                Entwurf, `replaced` Option im Entwurf ersetzt, `new_version`
                freigegebene Option im Entwurf der naechsten Revision ersetzt.

    SpecificationContent:
      type: object
      required: [options, rows]
      properties:
        name:
          type: string
          nullable: true
        source:
          type: string
          nullable: true
        source_reference:
          type: string
          nullable: true
        change_note:
          type: string
          nullable: true
        default_option:
          type: string
          nullable: true
        options:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/SpecificationOption'
        rows:
          type: array
          maxItems: 2000
          items:
            $ref: '#/components/schemas/SpecificationRow'

    SpecificationCreateInput:
      type: object
      description: "`type_id` ODER `inventory_id`; `name` oder `content.name`."
      properties:
        name:
          type: string
          maxLength: 255
        type_id:
          type: string
          maxLength: 80
          nullable: true
        inventory_id:
          type: string
          format: uuid
          nullable: true
        content:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationContent'
          nullable: true

    SpecificationIssue:
      type: object
      properties:
        path:
          type: string
          description: Stelle im Inhalt, etwa `rows.3.tolerances.class-a`
        message:
          type: string
        severity:
          type: string
          enum: [error, warning]

    SpecificationDiff:
      type: object
      properties:
        revision:
          $ref: '#/components/schemas/SpecificationRevisionSummary'
        against:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationRevisionSummary'
          nullable: true
        header:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              before: {}
              after: {}
        options:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              change:
                type: string
                enum: [added, changed, removed]
              name:
                type: string
              fields:
                type: array
                items:
                  type: string
        rows:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              change:
                type: string
                enum: [added, changed, removed]
              label:
                type: string
        tolerances:
          type: object
          description: Je Optionsschluessel die geaenderten Toleranzen
          additionalProperties:
            type: array
            items:
              type: object
              properties:
                row:
                  type: string
                label:
                  type: string
                before:
                  type: string
                  nullable: true
                after:
                  type: string
                  nullable: true

    SpecificationPayload:
      type: object
      description: |
        Austauschformat `calserver.specification` Version 2. Optionen und
        Zeilen tragen keine IDs; Ziel eines Imports ist `type_id` bzw.
        `inventory_id` der Anfrage, sonst `device_type_id` bzw. `inventory_id`
        des Eintrags. Revision, Status, Freigabezeitpunkt und Hash im Export
        sind Auskunft; der Import schreibt immer einen Entwurf.
      properties:
        format:
          type: string
          example: calserver.specification
        version:
          type: integer
          example: 2
        specifications:
          type: array
          items:
            allOf:
              - $ref: '#/components/schemas/SpecificationContent'
              - type: object
                properties:
                  device_type_id:
                    type: string
                    nullable: true
                  inventory_id:
                    type: string
                    nullable: true
                  revision:
                    type: integer
                    readOnly: true
                  status:
                    type: string
                    readOnly: true
                  released_at:
                    type: string
                    format: date-time
                    nullable: true
                    readOnly: true
                  content_hash:
                    type: string
                    nullable: true
                    readOnly: true

    SpecificationSchemaResource:
      type: object
      description: |
        Freiform-Selbstbeschreibung des Formats `calserver.specification`
        Version 2. Kein JSON:API-Resource-Objekt (kein `id`/`type`).
      properties:
        format:
          type: string
          example: calserver.specification
        version:
          type: integer
          example: 2
        summary:
          type: string
        option_kinds:
          type: array
          items:
            type: string
        option_fields:
          type: object
          additionalProperties: true
        row_kinds:
          type: object
          additionalProperties: true
        row_fields:
          type: object
          additionalProperties: true
        functions:
          type: array
          description: Funktionskatalog (Schluessel, Bezeichnungen, Einheiten)
          items:
            type: object
            additionalProperties: true
        lookup_rules:
          type: array
          items:
            type: string
        release_rules:
          type: array
          items:
            type: string
        payload_example:
          $ref: '#/components/schemas/SpecificationPayload'
        import_modes:
          type: object
          additionalProperties: true
          description: "Beschreibt `create`, `skip`, `replace`, `merge`."
      additionalProperties: true

    InventorySpecificationState:
      type: object
      properties:
        type_id:
          type: string
          nullable: true
        type_specification:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationListItem'
          nullable: true
        device_specification:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationListItem'
          nullable: true
        effective:
          type: string
          nullable: true
          enum: [type, device, null]
        effective_id:
          type: string
          nullable: true
        revision:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationRevisionSummary'
          nullable: true
        options:
          type: array
          items:
            $ref: '#/components/schemas/SpecificationOptionChoice'
        default_option:
          type: object
          nullable: true
          properties:
            specification_id:
              type: string
            option_key:
              type: string
            set_by:
              type: string
              nullable: true
            valid:
              type: boolean
              description: false, wenn die Option in der geltenden Revision nicht mehr existiert

    SpecificationOptionChoice:
      type: object
      properties:
        key:
          type: string
        name:
          type: string
        kind:
          type: string
          enum: [manufacturer, standard, customer, usage]
        is_default:
          type: boolean
        customer_id:
          type: string
          nullable: true

    CalibrationSpecificationState:
      type: object
      properties:
        specification:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationHead'
          nullable: true
        revision:
          type: object
          allOf:
            - $ref: '#/components/schemas/SpecificationRevisionSummary'
          nullable: true
        options:
          type: array
          items:
            $ref: '#/components/schemas/SpecificationOptionChoice'
        choice:
          type: object
          nullable: true
          properties:
            specification_id:
              type: string
            revision_id:
              type: string
            revision_number:
              type: integer
              nullable: true
            revision_status:
              type: string
              nullable: true
            option_key:
              type: string
            option_name:
              type: string
              nullable: true
            chosen_from:
              type: string
              enum: [device, customer, default, only, manual]
            chosen_by:
              type: string
              nullable: true
            chosen_at:
              type: string
              format: date-time
        preselected:
          type: string
          nullable: true
          description: Vorausgewaehlter Optionsschluessel, solange nichts gewaehlt ist
        preselected_from:
          type: string
          nullable: true
          enum: [device, customer, default, only, null]
        required:
          type: boolean
          description: Es gibt eine geltende Revision, aber noch keine Wahl
        reload_required:
          type: boolean
          description: Die Wahl passt nicht mehr zu den geladenen Schritten; Plan neu laden
        revision_outdated:
          type: boolean
          description: Seit der Wahl gibt es eine neuere freigegebene Revision

    # ── Sammellauf ─────────────────────────────────
    ProcedureBatch:
      type: object
      properties:
        id:
          type: string
          format: uuid
        workstation_id:
          type: string
          format: uuid
        procedure_template_id:
          type: string
          nullable: true
        template_version:
          type: string
          nullable: true
        reader_role:
          type: string
        reader_device_id:
          type: string
        reference_channel:
          type: string
          nullable: true
        status:
          type: string
          enum: [open, running, done, aborted]
        created_by:
          type: string
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        finished_at:
          type: string
          format: date-time
          nullable: true
        members:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              calibration_id:
                type: string
              run_id:
                type: string
                nullable: true
              channel:
                type: string
              position:
                type: integer
              run_status:
                type: string
                nullable: true
              awaits_preflight:
                type: boolean
        checkout:
          type: object
          nullable: true
          description: Ausgecheckt an den Messplatz (ADR 2026-11-08), sonst null.
          properties:
            workstation_id:
              type: string
              format: uuid
            workstation_name:
              type: string
              nullable: true
            checked_out_at:
              type: string
              format: date-time
            fetched_at:
              type: string
              format: date-time
              nullable: true
            members:
              type: integer
              description: Mitglieder, die noch am Messplatz liegen

    # ── Standard-Procedure-Zuordnung ────────────────
    StandardProcedureAttributes:
      type: object
      properties:
        procedure_uID:
          type: string
          format: uuid
        assignable_table:
          type: string
          enum: [types, inventories, calibrations]
        assignable_id:
          type: string
          format: uuid
          description: tID, MTAG oder Kalibrierungs-UUID, je nach `assignable_table`
        is_default:
          type: boolean
        procedure:
          type: object
          nullable: true
          description: Eingebettete Kurzform der zugeordneten Prozedur (in der Praxis immer mitgeladen).
          properties:
            id:
              type: string
              format: uuid
            procedure_name:
              type: string
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    StandardProcedureResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID der Zuordnung selbst (nicht die Prozedur-uID)
        type:
          type: string
          enum: [standard-procedure]
        attributes:
          $ref: '#/components/schemas/StandardProcedureAttributes'

    StandardProcedureInput:
      type: object
      required: [procedure_id]
      properties:
        procedure_id:
          type: string
          maxLength: 36
          description: "Muss in `procedures.uID` existieren (`exists`-Regel — sonst 422)."
        is_default:
          type: boolean
          default: false

    # ── Procedure Standard Type (prozedurzentrierte Sicht) ─
    ProcedureStandardTypeAttributes:
      type: object
      properties:
        type_id:
          type: string
          format: uuid
        type_label:
          type: string
          description: Anzeigename des Geraetetyps (Fallback auf die rohe UUID, falls der Typ geloescht wurde)
          example: "Fluke 87V"
        is_default:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ProcedureStandardTypeResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID der Zuordnung
        type:
          type: string
          enum: [procedure-standard-type]
        attributes:
          $ref: '#/components/schemas/ProcedureStandardTypeAttributes'

    # ── Test Step ───────────────────────────────────
    TestStepAttributes:
      type: object
      properties:
        calibration_id:
          type: string
          format: uuid
          nullable: true
          description: CTAG der Kalibrierung — kann null sein (siehe Endpunkt-Hinweise zu verwaisten Schritten).
        row_number:
          type: integer
          example: 1
        test_step:
          type: number
          nullable: true
          description: Hierarchische Schrittnummer (Major.Minor)
          example: 1.001
        description:
          type: string
          nullable: true
          example: "DC voltage, 6 V range"
        test_type:
          type: string
          nullable: true
          description: "Konzeptionell `Manual Test`, `Header`, `Rating`, `Message` oder `Comment` — beim Einzel-Schreiben serverseitig aber nicht auf diese Werte beschraenkt (siehe `TestStepInput`)."
          example: "Manual Test"
        test_mode:
          type: string
          nullable: true
          description: Anweisungstext (HTML)
          example: "<p>Set function to DCV, range 6V</p>"
        test_mode_markdown:
          type: string
          nullable: true
          example: "Set function to **DCV**, range 6 V"
        config:
          type: string
          nullable: true
          example: "NUMERIC,UUT INDICATED"
        range:
          type: number
          nullable: true
          example: 6.0
        test_value:
          type: number
          nullable: true
          example: 5.0
        test_value_p:
          type: string
          nullable: true
          description: SI-Praefix
        test_value_u:
          type: string
          nullable: true
          description: Einheit
          example: V
        auxalary:
          type: string
          nullable: true
        resolution:
          type: number
          nullable: true
          example: 0.0001
        tolerance:
          type: string
          nullable: true
          example: "0.05% 5U"
        uncertainty:
          type: number
          nullable: true
          example: 0.02
        created_by:
          type: string
          format: uuid
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    TestStepResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [test-step]
        attributes:
          $ref: '#/components/schemas/TestStepAttributes'

    TestStepInput:
      type: object
      description: |
        Gemeinsames Schema fuer Einzel-Erstellung/-Update (`POST/PUT` auf `.../test-steps`
        bzw. `/test-steps/{id}`), Bulk-Replace (`PUT /calibrations/{ctag}/test-steps`) und
        Template-Schritte (`/procedure-templates/{id}/steps`, `manual-template`). Abweichungen
        je Kontext: `row_number` nur bei Einzel-`POST` Pflicht (sonst serverseitig aus der
        Array-Reihenfolge abgeleitet); `test_type` ist bei Einzel-Endpunkten frei
        (`maxLength:255`, kein Enum), beim Bulk-Replace und bei Templates dagegen Pflicht mit
        Enum `Manual Test,Rating,Header,Message,Comment`; `description`/`config`/`test_value_p`/
        `test_value_u` haben bei Templates engere Laengen (1000/100/10/20 statt 500/500/255/255).
      properties:
        id:
          type: string
          description: Nur Bulk-Replace — vorhandene Schritt-UUID als Upsert-Schluessel (fehlt = neuer Schritt).
        row_number:
          type: integer
          description: Pflicht bei Einzel-`POST`.
          example: 1
        test_type:
          type: string
          maxLength: 255
          example: "Manual Test"
        description:
          type: string
          maxLength: 500
          example: "600 mV range"
        test_step:
          type: number
        range:
          type: number
          example: 0.6
        test_value:
          type: number
          example: 0.5
        resolution:
          type: number
          example: 0.0001
        uncertainty:
          type: number
          example: 0.02
        test_mode:
          type: string
        test_mode_markdown:
          type: string
        config:
          type: string
          maxLength: 500
        test_value_p:
          type: string
          maxLength: 255
        test_value_u:
          type: string
          maxLength: 255
          example: V
        auxalary:
          type: string
          maxLength: 255
        tolerance:
          type: string
          maxLength: 500
          example: "0.1% 1U"
        group_defaults:
          type: object
          additionalProperties: true
          description: |
            Nur `test_type: Header` in Templates: Default-Werte, die serverseitig an alle
            nachfolgenden Schritte bis zum naechsten Header vererbt werden (gleiche Sub-Felder,
            z.B. `config`, `test_value_u`, `tolerance`, `uncertainty`, `k`, `decision_rule`).

    # ── Result Capture (persistiertes Messergebnis) ─
    ResultCaptureAttributes:
      type: object
      description: |
        Persistiertes `Result::toApiArray()` — 11 umbenannte Spalten plus Rohspalten via
        `additionalProperties` (u.a. `test_status`, `tol_err`, `guardband_meth`, `remark`,
        `comment`, `images`).
      properties:
        row_number:
          type: integer
          example: 1
        test_description:
          type: string
          nullable: true
        fixed_quantity:
          type: string
          nullable: true
          example: "5.000"
        variable_quantity:
          type: string
          nullable: true
          example: "5.002"
        lower_limit:
          type: string
          nullable: true
          example: "4.995"
        upper_limit:
          type: string
          nullable: true
          example: "5.005"
        pass_fail:
          type: string
          nullable: true
          example: Pass
        expanded_uncertainty:
          type: string
          nullable: true
          example: "0.02"
        standard_uncertainty:
          type: string
          nullable: true
        coverage_factor:
          type: string
          nullable: true
          example: "2"
        tolerance_reference:
          type: string
          nullable: true
          example: Nominal
        tolerance_positive:
          type: string
          nullable: true
          example: "0.005"
        tolerance_negative:
          type: string
          nullable: true
          example: "-0.005"
        standard_deviation:
          type: string
          nullable: true
        test_status:
          type: string
          nullable: true
          enum: [Pass, Fail, Conditional, ConditionalFail]
      additionalProperties: true

    ResultCaptureResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: uID des zugehoerigen TestStep (nicht des Result-Datensatzes)
        type:
          type: string
          enum: [test-step-result]
        attributes:
          $ref: '#/components/schemas/ResultCaptureAttributes'

    # ── Calibration Capture Plan ────────────────────
    CalibrationCaptureAttributes:
      type: object
      description: |
        "CapturePlan" — welche Prozedur/Vorlage fuer die Messwertaufnahme dieser Kalibrierung
        aktiv ist und welche Aktionen zur Verfuegung stehen. `message` ist teils deutschsprachig
        (Original-Anwendungstext, siehe Beispiele).
      properties:
        status:
          type: string
          enum: [ok, no_procedure, no_template]
        message:
          type: string
          nullable: true
        procedure:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
        source:
          type: string
          nullable: true
          enum: [calibration, device, type]
          description: Woher die aktive Prozedur stammt — explizite Auswahl, Geraet oder Geraetetyp.
        template:
          type: object
          nullable: true
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
              nullable: true
            file_name:
              type: string
            file_type:
              type: string
            version:
              type: string
            released:
              type: string
              nullable: true
            kind:
              type: string
            kind_label:
              type: string
        kind:
          type: string
          nullable: true
        kind_label:
          type: string
          nullable: true
        recommended:
          type: string
          nullable: true
          description: Empfohlene `action` aus `options`
        options:
          type: array
          items:
            type: object
            properties:
              action:
                type: string
                enum: [internal_run, external_launch, download, manual]
              label:
                type: string
              description:
                type: string
              available:
                type: boolean
              reason:
                type: string
                nullable: true
                description: Begruendung, wenn `available:false`
              recommended:
                type: boolean
        procedure_choices:
          type: array
          description: Alle fuer dieses Geraet in Frage kommenden Prozeduren (fuer einen manuellen Wechsel per `PUT .../procedure`).
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
              source:
                type: string
                enum: [calibration, device, type]
              is_default:
                type: boolean
              active:
                type: boolean
        can_switch:
          type: boolean

    CalibrationCaptureResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: CTAG der Kalibrierung
        type:
          type: string
          enum: [calibration-capture]
        attributes:
          $ref: '#/components/schemas/CalibrationCaptureAttributes'

    # ── Procedure Run ───────────────────────────────
    ProcedureRunAttributes:
      type: object
      properties:
        calibration_id:
          type: string
          format: uuid
          nullable: true
        procedure_template_id:
          type: string
          format: uuid
          nullable: true
        template_version:
          type: string
          nullable: true
          example: "1.0"
        template_name:
          type: string
          nullable: true
          example: "DMM Verfahren"
        template_hash:
          type: string
          nullable: true
        steps_modified_at:
          type: string
          format: date-time
          nullable: true
        status:
          type: string
          enum: [draft, in_progress, completed, aborted]
        result_rollup:
          type: string
          nullable: true
          enum: [Pass, Fail, Conditional, Incomplete]
        started_by:
          type: string
          format: uuid
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ProcedureRunResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [procedure-run]
        attributes:
          $ref: '#/components/schemas/ProcedureRunAttributes'
    # ── Customer ───────────────────────────────────
    CustomerAttributes:
      type: object
      description: |
        HasDynamicFields-Standardserialisierung — `id` erscheint dadurch dupliziert auch hier
        (siehe CustomerResource.id), zusaetzlich zum rohen `custom_fields`-Blob.
      properties:
        id:
          type: string
          format: uuid
          description: Dupliziert die Top-Level-Resource-ID (HasDynamicFields-Eigenheit)
        name:
          type: string
          maxLength: 48
          example: "Test GmbH"
        customer_number:
          type: string
          nullable: true
          maxLength: 48
          example: "KD-0001"
        street:
          type: string
          nullable: true
          maxLength: 48
          example: "Teststraße 1"
        zip:
          type: string
          nullable: true
          maxLength: 48
          example: "12345"
        city:
          type: string
          nullable: true
          maxLength: 48
          example: "Berlin"
        country:
          type: string
          nullable: true
          maxLength: 48
          example: "DE"
        phone:
          type: string
          nullable: true
          maxLength: 48
          example: "+49 30 123456"
        fax:
          type: string
          nullable: true
          maxLength: 48
        email:
          type: string
          nullable: true
          maxLength: 48
          example: "info@test-gmbh.de"
        website:
          type: string
          nullable: true
          maxLength: 48
        contact_person:
          type: string
          nullable: true
          maxLength: 48
          example: "Hans Müller"
        custom_fields:
          type: object
          nullable: true
          additionalProperties: true
          description: Rohes JSON mit mandantenspezifischen Custom-Field-Werten
        data_source:
          type: string
          nullable: true
          maxLength: 3
          example: "cal"
        external_facility_id:
          type: string
          nullable: true
          maxLength: 36
        price_category_id:
          type: string
          nullable: true
          maxLength: 80
          description: FK auf die Preisgruppe (`price_category.uID`)
          example: "pc-standard-uid"
        price_category_id_label:
          type: string
          nullable: true
          description: |
            Name der Preisgruppe, aufgeloest aus `price_category_id`. `null`, wenn kein
            Schluessel gesetzt ist oder die Gruppe geloescht wurde. Grid, Detailmaske
            und Export zeigen diesen Wert statt der uID.
          example: "Standard"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        deleted_at:
          type: string
          format: date-time
          nullable: true
          description: Soft-Delete-Zeitstempel
      additionalProperties: true

    CustomerResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID des Kunden (KTAG)
        type:
          type: string
          enum: [customer]
        attributes:
          $ref: '#/components/schemas/CustomerAttributes'

    CustomerCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/CustomerResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
            api_version:
              type: string
              example: "2.0"
        links:
          type: object
          properties:
            self:
              type: string
            first:
              type: string
            last:
              type: string
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    CustomerInput:
      type: object
      description: |
        Achtung: alle Textfelder sind auf 48 Zeichen begrenzt (nicht 255). Zusaetzliche
        mandantenspezifische Custom-Fields werden dynamisch als weitere Top-Level-Schluessel
        akzeptiert (siehe GET /fields/customer).
      required: [name]
      properties:
        name:
          type: string
          maxLength: 48
          example: "Neue Firma GmbH"
        customer_number:
          type: string
          maxLength: 48
        street:
          type: string
          maxLength: 48
        zip:
          type: string
          maxLength: 48
        city:
          type: string
          maxLength: 48
        country:
          type: string
          maxLength: 48
        phone:
          type: string
          maxLength: 48
        fax:
          type: string
          maxLength: 48
        email:
          type: string
          format: email
          maxLength: 48
        website:
          type: string
          maxLength: 48
        contact_person:
          type: string
          maxLength: 48
        price_category_id:
          type: string
          maxLength: 80
          nullable: true
          description: |
            uID einer bestehenden Preisgruppe (`GET /price-categories`, Picker
            `GET /lookup/price-categories`). Ein unbekannter Schluessel wird mit 422
            abgelehnt; `null` bzw. `""` heisst "keine Preisgruppe".

    CustomerUpdateInput:
      type: object
      description: |
        Partial Update — nur uebergebene Felder werden geaendert. `name` darf, falls uebergeben,
        nicht leer sein.
      properties:
        name:
          type: string
          maxLength: 48
        customer_number:
          type: string
          maxLength: 48
        street:
          type: string
          maxLength: 48
        zip:
          type: string
          maxLength: 48
        city:
          type: string
          maxLength: 48
        country:
          type: string
          maxLength: 48
        phone:
          type: string
          maxLength: 48
        fax:
          type: string
          maxLength: 48
        email:
          type: string
          format: email
          maxLength: 48
        website:
          type: string
          maxLength: 48
        contact_person:
          type: string
          maxLength: 48
        price_category_id:
          type: string
          maxLength: 80
          nullable: true
          description: |
            uID einer bestehenden Preisgruppe. Geprueft wird nur ein *geaenderter* Wert:
            ein unveraenderter Schluessel darf ins Leere zeigen (die Gruppe kann geloescht
            worden sein), damit ein solcher Kunde speicherbar bleibt. `null` bzw. `""`
            entfernt die Zuordnung.

    # ── Contact ────────────────────────────────────
    ContactAttributes:
      type: object
      description: Kein Soft-Delete, keine Timestamps (weder created_at/updated_at noch deleted_at).
      properties:
        name:
          type: string
          maxLength: 255
          example: "Test GmbH"
        firstname:
          type: string
          nullable: true
          maxLength: 255
          example: "Hans"
        contact_person:
          type: string
          nullable: true
          maxLength: 255
          description: Schreibziel fuer das doppelte `contact`-Feld
          example: "Hans Müller"
        contact:
          type: string
          nullable: true
          description: Nur lesend — Duplikat von `contact_person`, nicht direkt beschreibbar
          example: "Hans Müller"
        street:
          type: string
          maxLength: 255
          example: "Teststraße 1"
        zip:
          type: string
          maxLength: 20
          example: "12345"
        location:
          type: string
          maxLength: 255
          example: "Berlin"
        state:
          type: string
          maxLength: 255
          example: "Berlin"
        country:
          type: string
          maxLength: 255
          example: "DE"
        phone:
          type: string
          nullable: true
          maxLength: 255
          example: "+49 30 123456"
        fax:
          type: string
          nullable: true
          maxLength: 255
        email:
          type: string
          nullable: true
          maxLength: 255
          example: "hans@test-gmbh.de"
        label:
          type: string
          maxLength: 255
          example: "Hauptkontakt"
        type:
          type: string
          maxLength: 100
          description: "Freitext, z.B. `invoice`, `delivery`"
          example: "invoice"
        status:
          type: integer
          description: "0 = inaktiv, 1 = aktiv, 2 = geloescht (wird aus Listen immer ausgeschlossen)"
          example: 1
        favorite:
          type: integer
          enum: [0, 1]
          example: 1
        customer_id:
          type: string
          nullable: true
          maxLength: 80
          description: FK auf Customer (DB-Spalte `customer_uID`)
          example: "00000000-0000-4000-a000-000000000001"
        custom_fields:
          type: object
          nullable: true
          additionalProperties: true

    ContactResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID des Kontakts (uID)
        type:
          type: string
          enum: [contact]
        attributes:
          $ref: '#/components/schemas/ContactAttributes'

    ContactCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ContactResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
            api_version:
              type: string
              example: "2.0"
        links:
          type: object
          properties:
            self:
              type: string
            first:
              type: string
            last:
              type: string
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    ContactInput:
      type: object
      description: |
        Kein dediziertes FormRequest. `contact`, `favorite` und `status` sind nicht schreibbar
        (werden serverseitig auf "" / 0 / 1 gesetzt).
      required: [name]
      properties:
        name:
          type: string
          maxLength: 255
          example: "Neue Firma"
        firstname:
          type: string
          maxLength: 255
        contact_person:
          type: string
          maxLength: 255
          example: "Max Mustermann"
        street:
          type: string
          maxLength: 255
        zip:
          type: string
          maxLength: 20
        location:
          type: string
          maxLength: 255
        state:
          type: string
          maxLength: 255
        country:
          type: string
          maxLength: 255
        phone:
          type: string
          maxLength: 255
        fax:
          type: string
          maxLength: 255
        email:
          type: string
          format: email
          maxLength: 255
        label:
          type: string
          maxLength: 255
        type:
          type: string
          maxLength: 100
        customer_id:
          type: string
          maxLength: 80

    ContactUpdateInput:
      type: object
      description: |
        Partial Update. Anders als bei `ContactInput` ist `favorite` hier direkt setzbar
        (`contact`/`status` bleiben weiterhin nicht schreibbar).
      properties:
        name:
          type: string
          maxLength: 255
        firstname:
          type: string
          maxLength: 255
        contact_person:
          type: string
          maxLength: 255
        street:
          type: string
          maxLength: 255
        zip:
          type: string
          maxLength: 20
        location:
          type: string
          maxLength: 255
        state:
          type: string
          maxLength: 255
        country:
          type: string
          maxLength: 255
        phone:
          type: string
          maxLength: 255
          example: "+49 30 999999"
        fax:
          type: string
          maxLength: 255
        email:
          type: string
          format: email
          maxLength: 255
        label:
          type: string
          maxLength: 255
        type:
          type: string
          maxLength: 100
        customer_id:
          type: string
          maxLength: 80
        favorite:
          type: integer
          enum: [0, 1]
          example: 0

    # ── Location (Leih-/Bewegungsregister) ────────
    LocationAttributes:
      type: object
      description: |
        `location_1`/`location_2`/`location_3` sind hier auf 36 Zeichen begrenzt — bei
        `LocationBulkAssignInput` sind es nur 20 (Formular-Inkonsistenz, kein Tippfehler).
      properties:
        inventory_id:
          type: string
          maxLength: 80
          description: FK auf Inventory, nach Anlage nicht mehr aenderbar
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        location_1:
          type: string
          nullable: true
          maxLength: 36
          example: "Berlin"
        location_2:
          type: string
          nullable: true
          maxLength: 36
          example: "Labor A"
        location_3:
          type: string
          nullable: true
          maxLength: 36
          example: "Schrank 3"
        location_4:
          type: string
          nullable: true
          maxLength: 10
          example: "R204"
        location_5:
          type: string
          nullable: true
          maxLength: 8
          example: "B1"
        location_date:
          type: string
          description: Datum/Datetime als String, auf Store/BulkAssign nicht strikt als `date` validiert
          example: "2026-05-01"
        location_time:
          type: string
          nullable: true
          maxLength: 8
          description: Standard "00:00:00"
          example: "08:00:00"
        rental_end:
          type: string
          format: date
          nullable: true
        is_active:
          type: integer
          nullable: true
          enum: [0, 1]
          example: 1
        customer_id:
          type: string
          nullable: true
          format: uuid
          description: Entleiher (FK auf Customer)
        delivery_customer_id:
          type: string
          nullable: true
          format: uuid
          description: Nur lesend — aktuell in keinem FormRequest schreibbar
        customer_contact_id:
          type: string
          nullable: true
          description: "Nur lesend — FK auf contact.uID, aktuell in keinem FormRequest schreibbar"
        delivery_contact_id:
          type: string
          nullable: true
          description: Nur lesend — aktuell in keinem FormRequest schreibbar
        status:
          type: string
          nullable: true
          maxLength: 100
          description: Status-UUID oder Legacy-Rohtitel
          example: "Reservation"
        status_label:
          type: string
          nullable: true
        data_source:
          type: string
          nullable: true
          example: "cal"
        custom_fields:
          type: object
          nullable: true
          additionalProperties: true
        customer_label:
          type: string
          nullable: true
        inventory_label:
          type: string
          nullable: true
        delivery_customer_label:
          type: string
          nullable: true
        customer_contact_label:
          type: string
          nullable: true
        delivery_contact_label:
          type: string
          nullable: true

    LocationResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID des Standort-/Verleiheintrags (LTAG)
        type:
          type: string
          enum: [location]
        attributes:
          $ref: '#/components/schemas/LocationAttributes'
        relationships:
          type: object
          nullable: true
          description: Nur vorhanden, wenn `inventory` eager geladen wurde
          properties:
            inventory:
              type: object
              properties:
                data:
                  type: object
                  properties:
                    id:
                      type: string
                    type:
                      type: string
                      enum: [inventory]

    LocationCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/LocationResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
            api_version:
              type: string
              example: "2.0"
        links:
          type: object
          properties:
            self:
              type: string
            first:
              type: string
            last:
              type: string
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    LocationInput:
      type: object
      description: |
        `check_availability=true` prueft optional auf Ueberschneidung (siehe POST /locations).
        Nicht schreibbar (kein Feld in diesem FormRequest): `delivery_customer_id`,
        `customer_contact_id`, `delivery_contact_id`, `custom_fields`, `data_source`.
      required: [inventory_id]
      properties:
        inventory_id:
          type: string
          maxLength: 80
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        location_1:
          type: string
          nullable: true
          maxLength: 36
        location_2:
          type: string
          nullable: true
          maxLength: 36
        location_3:
          type: string
          nullable: true
          maxLength: 36
        location_4:
          type: string
          nullable: true
          maxLength: 10
        location_5:
          type: string
          nullable: true
          maxLength: 8
        location_date:
          type: string
          maxLength: 255
        location_time:
          type: string
          nullable: true
          maxLength: 8
        rental_end:
          type: string
          format: date
          nullable: true
        is_active:
          type: integer
          nullable: true
          enum: [0, 1]
        customer_id:
          type: string
          nullable: true
          maxLength: 80
        status:
          type: string
          nullable: true
          maxLength: 80
          description: Formularbreite 80 — schmaler als die Response-Spalte (100)
        check_availability:
          type: boolean
          description: Loest bei `true` eine Ueberschneidungspruefung aus (opt-in). Nicht persistiert.

    LocationUpdateInput:
      type: object
      description: "`inventory_id` ist beim Update nicht enthalten/aenderbar."
      properties:
        location_1:
          type: string
          nullable: true
          maxLength: 36
        location_2:
          type: string
          nullable: true
          maxLength: 36
        location_3:
          type: string
          nullable: true
          maxLength: 36
        location_4:
          type: string
          nullable: true
          maxLength: 10
        location_5:
          type: string
          nullable: true
          maxLength: 8
        location_date:
          type: string
          maxLength: 255
        location_time:
          type: string
          nullable: true
          maxLength: 8
        rental_end:
          type: string
          format: date
          nullable: true
        is_active:
          type: integer
          nullable: true
          enum: [0, 1]
        customer_id:
          type: string
          nullable: true
          maxLength: 80
        status:
          type: string
          nullable: true
          maxLength: 80
        check_availability:
          type: boolean
          description: Loest bei `true` eine Ueberschneidungspruefung aus. Nicht persistiert.
        send_receipt:
          type: boolean
          default: true
          description: "`false` unterdrueckt die Update-Benachrichtigungsmail. Nicht persistiert."

    LocationBulkAssignInput:
      type: object
      description: |
        Kein `rental_end`-Feld — Sammelbuchungen ohne Rueckgabedatum, danach ggf. einzeln per
        `PATCH /locations/{ltag}/schedule` setzen. `location_1..3` sind hier auf **20** Zeichen
        begrenzt (Einzel-Anlage: 36). `is_active` ist hier nicht nullable (Server-Default 1).
      required: [inventory_ids]
      properties:
        inventory_ids:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: string
            maxLength: 80
        copy_from_last:
          type: boolean
          description: Uebernimmt location_1..5 vom zuletzt bekannten Eintrag jedes Inventars
        check_availability:
          type: boolean
        location_1:
          type: string
          nullable: true
          maxLength: 20
        location_2:
          type: string
          nullable: true
          maxLength: 20
        location_3:
          type: string
          nullable: true
          maxLength: 20
        location_4:
          type: string
          nullable: true
          maxLength: 10
        location_5:
          type: string
          nullable: true
          maxLength: 8
        location_date:
          type: string
          maxLength: 255
        location_time:
          type: string
          nullable: true
          maxLength: 8
        is_active:
          type: integer
          enum: [0, 1]
          description: Server-Default 1, falls weggelassen
        customer_id:
          type: string
          nullable: true
          maxLength: 80
        status:
          type: string
          nullable: true
          maxLength: 80

    # ── Reservation / Loan Calendar ────────────────
    ReservationEventAttributes:
      type: object
      properties:
        inventory_id:
          type: string
        inventory_label:
          type: string
          nullable: true
        title:
          type: string
          description: "Format: `[Kunde] Seriennummer - Hersteller - Beschreibung`"
          example: "[Test GmbH] SN-CAL-1 - Fluke - Multimeter"
        start:
          type: string
          format: date
        end:
          type: string
          format: date
        has_end:
          type: boolean
        all_day:
          type: boolean
          description: Immer `true`
        location_time:
          type: string
        status:
          type: string
          nullable: true
        status_label:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
          example: "#BB54FF"
        text_color:
          type: string
          nullable: true
          example: "#FFFFFF"
        is_active:
          type: integer
        customer_id:
          type: string
          nullable: true
        customer_label:
          type: string
          nullable: true
        set_id:
          type: string
          nullable: true
        editable:
          type: boolean
          description: |
            Spiegelt die Berechtigung des AUFRUFERS (nicht pro Zeile) — bei fehlendem
            `reservation_edit` fuer alle Events `false`.

    ReservationEventResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID des zugrundeliegenden Standort-/Verleiheintrags (LTAG)
        type:
          type: string
          enum: [reservation-event]
        attributes:
          $ref: '#/components/schemas/ReservationEventAttributes'

    RentalUnitAttributes:
      type: object
      properties:
        kind:
          type: string
          enum: [single, set]
        asset_number:
          type: string
        serial_number:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        manufacturer:
          type: string
          nullable: true
        model:
          type: string
          nullable: true
        customer_id:
          type: string
          nullable: true
        category_labels:
          type: array
          items:
            type: string
        part_count:
          type: integer
          description: "0 bei `kind: single`"
        availability:
          type: object
          nullable: true
          description: "`null`, solange nicht beide `from`/`to` in der Anfrage gesetzt waren"
          properties:
            state:
              type: string
              enum: [free, partial, blocked]
            blocked_from:
              type: string
              format: date
              nullable: true
            blocked_to:
              type: string
              format: date
              nullable: true
            blocked_parts:
              type: integer
            next_free_from:
              type: string
              format: date
              nullable: true
        blocked_reason:
          type: string
          nullable: true
          enum: [status, parent_not_released, parts_not_visible]
        bookable:
          type: boolean
        parts:
          type: array
          description: "Nur bei `kind: set` und `include_parts=true` befuellt"
          items:
            type: object
            properties:
              inventory_id:
                type: string
                format: uuid
              asset_number:
                type: string
              serial_number:
                type: string
                nullable: true
              description:
                type: string
                nullable: true
              available:
                type: boolean
                nullable: true
              conflict:
                type: object
                nullable: true
                additionalProperties: true

    RentalUnitResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID des Inventarobjekts (MTAG) — Einzelgeraet oder Set-Wurzel
        type:
          type: string
          enum: [rental-unit]
        attributes:
          $ref: '#/components/schemas/RentalUnitAttributes'

    RentalUnitCategoryResource:
      type: object
      description: Nur ausleihbare Kategorien mit total>0 werden ueberhaupt zurueckgegeben
      properties:
        id:
          type: string
          format: uuid
          description: UUID der Kategorie
        type:
          type: string
          enum: [rental-unit-category]
        attributes:
          type: object
          properties:
            parent_id:
              type: string
              nullable: true
            name:
              type: string
            color:
              type: string
              nullable: true
            text_color:
              type: string
              nullable: true
            total:
              type: integer
            free:
              type: integer
              nullable: true
              description: "`null`, solange nicht beide `from`/`to` gesetzt sind"

    ReservationSetAttributes:
      type: object
      properties:
        root_inventory_id:
          type: string
          format: uuid
        created_by:
          type: string
          format: uuid
          nullable: true
        note:
          type: string
          nullable: true
          maxLength: 255
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        location_ids:
          type: array
          description: Inkl. der Location-Zeile des Wurzelgeraets
          items:
            type: string
            format: uuid
        part_count:
          type: integer
          description: Zaehlt nur die Teile, exklusive Wurzelgeraet

    ReservationSetResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID des Reservierungs-Sets
        type:
          type: string
          enum: [reservation-set]
        attributes:
          $ref: '#/components/schemas/ReservationSetAttributes'
    # ── Ticket ──────────────────────────────────────
    TicketAttributes:
      type: object
      description: |
        Ticket-Attribute (`support_tickets`). Relationship-Bloecke im umgebenden
        `TicketResource` sind `whenLoaded` und degradieren zu einem LEEREN ARRAY
        `[]`, wenn sie fuer die jeweilige Aktion nicht mitgeladen wurden — nicht zu
        `{"data": null}`. Z.B. ist `group` nur bei `show`, `update`, `assignGroup`,
        `unassignGroup`, `assignUser` und `syncCcRecipients` gefuellt, sonst `[]`.
      properties:
        title:
          type: string
          description: Titel (Pflichtfeld bei Erstellung)
          example: "Display defekt"
        alias:
          type: string
          description: Server-generierter Slug aus `title`, nach Erstellung unveraenderlich
          example: "display-defekt"
        description:
          type: string
          nullable: true
          example: "Bildschirm zeigt keine Werte mehr an."
        description_markdown:
          type: string
          nullable: true
        ticket_version:
          type: integer
          nullable: true
          description: FK zu `support_tickets_versions.id`
          example: 3
        fixed_in:
          type: integer
          nullable: true
          description: FK zu `support_tickets_versions.id`
        reported_by_id:
          type: string
          format: uuid
          description: Melder-UUID, serverseitig aus dem authentifizierten Benutzer gesetzt — nicht vom Client setzbar
          example: "a1b2c3d4-e5f6-7890-abcd-000000000001"
        assigned_to_id:
          type: string
          description: |
            Zugewiesener Benutzer (UUID). Leerer String `""`, wenn nicht
            zugewiesen — NICHT `null`.
          example: ""
        assigned_to_group:
          type: integer
          nullable: true
          description: FK zu `group.id`
          example: 5
        ticket_type:
          type: integer
          nullable: true
          description: FK zu `support_tickets_types.id`
          example: 1
        priority:
          type: integer
          nullable: true
          description: Kann serverseitig durch die Risikomatrix-Berechnung ueberschrieben werden (siehe `TicketInput`)
          example: 7
        ticket_status:
          type: string
          format: uuid
          nullable: true
          maxLength: 36
          description: |
            FK zu `status.uID` (type=`support_tickets`). Store/Update pruefen
            NICHT, ob dieser FK existiert — nur `POST .../change-status` tut das.
          example: "c6f35048-6556-5dba-602c-10eed9cb2473"
        ticket_category:
          type: integer
          nullable: true
          description: FK zu `support_tickets_categories.id`
          example: 2
        project_id:
          type: integer
          nullable: true
          description: FK zu `support_projects.id`
          example: 4
        milestone:
          type: integer
          nullable: true
          description: FK zu `support_milestones.id`
          example: 6
        risk:
          type: integer
          nullable: true
          description: Bei jedem Lesezugriff neu berechnet — der gespeicherte Spaltenwert ist nicht massgeblich
          example: 12
        risk3:
          type: integer
          nullable: true
          example: 2
        keywords:
          type: string
          nullable: true
          description: Wird vom `search`-Filter der Ticketliste zusaetzlich zu `title` durchsucht (ODER-verknuepft)
          example: "hardware,urgent"
        last_comment_id:
          type: integer
          example: 0
        created_at:
          type: string
          example: "2026-08-03 09:12:00"
        updated_at:
          type: string
          nullable: true
          example: "2026-08-03 09:40:11"
        risk_color:
          type: string
          nullable: true
          description: "Berechnetes Feld. Reiner Hex-Wert OHNE fuehrendes `#` (im Gegensatz zu Prioritaets-/Status-Farben, die `#` behalten)."
          example: "FF0000"

    TicketResource:
      type: object
      properties:
        id:
          type: integer
          description: Ticket-ID
          example: 42
        type:
          type: string
          enum: [ticket]
        attributes:
          $ref: '#/components/schemas/TicketAttributes'
        relationships:
          type: object
          description: |
            Alle Eintraege sind `whenLoaded` und zeigen hier die GELADENE Form —
            bei fehlendem Eager-Loading degradiert der jeweilige Schluessel
            stattdessen zu `[]` (siehe `TicketAttributes`).
          properties:
            version:
              type: object
              nullable: true
              description: "Kein `type`-Schluessel."
              properties:
                id: { type: integer }
                title: { type: string }
                risk: { type: integer, nullable: true }
            milestone:
              type: object
              nullable: true
              description: "Kein `type`-Schluessel."
              properties:
                id: { type: integer }
                title: { type: string }
                risk: { type: integer, nullable: true }
            risk3:
              type: object
              nullable: true
              description: "Kein `type`-Schluessel."
              properties:
                id: { type: integer }
                title: { type: string }
                risk: { type: integer, nullable: true }
            reporter:
              type: object
              nullable: true
              properties:
                id: { type: string, format: uuid }
                type: { type: string, enum: [user] }
                name: { type: string }
            assignee:
              type: object
              nullable: true
              properties:
                id: { type: string, format: uuid }
                type: { type: string, enum: [user] }
                name: { type: string }
            project:
              type: object
              nullable: true
              properties:
                id: { type: integer }
                type: { type: string, enum: [project] }
                title: { type: string }
            type:
              type: object
              nullable: true
              description: "Ticket-Typ. Kein `type`-Schluessel im Objekt selbst."
              properties:
                id: { type: integer }
                title: { type: string }
            priority:
              type: object
              nullable: true
              description: "Kein `type`-Schluessel. Farben behalten das fuehrende `#`."
              properties:
                id: { type: integer }
                title: { type: string }
                background_color: { type: string, example: "#ff0000" }
                color: { type: string, example: "#ffffff" }
            category:
              type: object
              nullable: true
              description: "Kein `type`-Schluessel."
              properties:
                id: { type: integer }
                title: { type: string }
            status:
              type: object
              nullable: true
              description: "Kein `type`-Schluessel. `id`=status.uID, `background_color`=status.color, `color`=status.text_color."
              properties:
                id: { type: string, format: uuid }
                title: { type: string }
                background_color: { type: string }
                color: { type: string }
            group:
              type: object
              nullable: true
              properties:
                id: { type: integer }
                type: { type: string, enum: [group] }
                name: { type: string }
            cc_recipients:
              type: object
              description: "Immer ein Array, moeglicherweise leer, NIE `null` — auch wenn nicht mitgeladen bleibt es `{\"data\":[]}`, kein `[]` auf oberster Ebene."
              properties:
                data:
                  type: array
                  items:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      type: { type: string, enum: [user] }
                      name: { type: string }

    TicketCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/TicketResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
        links:
          type: object
          properties:
            self:
              type: string
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    TicketInput:
      type: object
      required: [title]
      description: |
        `priority` kann serverseitig durch die Risikomatrix neu berechnet werden,
        wenn `ticket_version`/`milestone`/`risk3` gesetzt sind/sich aendern — der
        uebergebene Wert ist dann nicht zwingend der gespeicherte. `ticket_status`
        wird hier NICHT gegen die Status-Tabelle geprueft (nur
        `POST .../change-status` tut das).
      properties:
        title:
          type: string
          maxLength: 255
          example: "Display defekt"
        description:
          type: string
          nullable: true
          example: "Bildschirm zeigt keine Werte mehr an."
        description_markdown:
          type: string
          nullable: true
        project_id:
          type: integer
          nullable: true
        ticket_type:
          type: integer
          nullable: true
          example: 1
        priority:
          type: integer
          nullable: true
          example: 2
        ticket_status:
          type: string
          nullable: true
          maxLength: 36
        ticket_category:
          type: integer
          nullable: true
        ticket_version:
          type: integer
          nullable: true
        fixed_in:
          type: integer
          nullable: true
        milestone:
          type: integer
          nullable: true
        risk3:
          type: integer
          nullable: true
        assigned_to_id:
          type: string
          nullable: true
          maxLength: 36
        assigned_to_group:
          type: integer
          nullable: true
        keywords:
          type: string
          nullable: true
          maxLength: 255
        cc_recipients:
          type: array
          description: Empfaenger-UUIDs. Vollstaendiger Ersatz via `sync()` (siehe auch `PUT .../cc-recipients`).
          items:
            type: string
            maxLength: 36
          example: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]

    TicketUpdateInput:
      type: object
      description: |
        Partial Update (`sometimes`) — nur uebergebene Felder werden geaendert,
        `title` ist hier NICHT Pflicht. Gleiche Feldregeln wie `TicketInput`,
        inkl. der serverseitigen Risikomatrix-Neuberechnung von `priority`.
      properties:
        title:
          type: string
          maxLength: 255
        description:
          type: string
          nullable: true
        description_markdown:
          type: string
          nullable: true
        project_id:
          type: integer
          nullable: true
        ticket_type:
          type: integer
          nullable: true
        priority:
          type: integer
          nullable: true
        ticket_status:
          type: string
          nullable: true
          maxLength: 36
        ticket_category:
          type: integer
          nullable: true
        ticket_version:
          type: integer
          nullable: true
        fixed_in:
          type: integer
          nullable: true
        milestone:
          type: integer
          nullable: true
        risk3:
          type: integer
          nullable: true
        assigned_to_id:
          type: string
          nullable: true
          maxLength: 36
        assigned_to_group:
          type: integer
          nullable: true
        keywords:
          type: string
          nullable: true
          maxLength: 255
        cc_recipients:
          type: array
          description: Empfaenger-UUIDs. Vollstaendiger Ersatz via `sync()`.
          items:
            type: string
            maxLength: 36

    # ── Ticket Comment ──────────────────────────────
    TicketCommentAttributes:
      type: object
      properties:
        ticket_id:
          type: integer
          example: 42
        posted:
          type: string
          description: Server-gesetzt (`now()`), nicht vom Client setzbar
          example: "2026-08-03 09:15:00"
        content:
          type: string
          nullable: true
          example: "New comment"
        content_markdown:
          type: string
          nullable: true
        first_comment:
          type: boolean
          description: "Aus int gecastet — bei API-erzeugten Kommentaren immer `false`."
          example: false

    TicketCommentResource:
      type: object
      properties:
        id:
          type: integer
          example: 7
        type:
          type: string
          enum: [ticket-comment]
        attributes:
          $ref: '#/components/schemas/TicketCommentAttributes'
        relationships:
          type: object
          description: "`author` ist in der Praxis immer geladen, sonst `[]` (siehe `TicketAttributes`)."
          properties:
            author:
              type: object
              nullable: true
              properties:
                id: { type: string, format: uuid }
                type: { type: string, enum: [user] }
                name: { type: string }

    TicketCommentInput:
      type: object
      required: [content]
      description: |
        Gilt fuer POST (neuer Kommentar) und PUT (Kommentar bearbeiten — dort
        inline validiert statt per FormRequest-Klasse). POST setzt serverseitig
        `user_id`/`posted`/`first_comment=0` und aktualisiert
        `last_comment_id`/`updated_at` des Tickets.
      properties:
        content:
          type: string
          example: "Reproduced on 6.0.3, escalating."
        content_markdown:
          type: string
          nullable: true

    # ── Ticket ↔ Inventory Link ─────────────────────
    TicketInventoryLinkAttributes:
      type: object
      description: |
        Flacher Dump der Verknuepfungstabelle `support_tickets_inventories` plus
        Inventar-Anzeigefeldern. `asset_number`/`serial_number`/`manufacturer`/
        `model`/`description` sind `null`, wenn `mtag` auf kein (mehr)
        existierendes Inventarobjekt zeigt — der Link selbst bleibt davon
        unberuehrt bestehen.
      properties:
        ticket_id:
          type: integer
          example: 42
        mtag:
          type: string
          format: uuid
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        asset_number:
          type: string
          nullable: true
          example: "INV-4711"
        serial_number:
          type: string
          nullable: true
          example: "SN-0815"
        manufacturer:
          type: string
          nullable: true
          example: "ACME"
        model:
          type: string
          nullable: true
          example: "DPI 620"
        description:
          type: string
          nullable: true

    TicketInventoryLinkResource:
      description: |
        Kein JSON:API `id`/`type`/`attributes`-Wrapper — jede Zeile in
        `GET /tickets/{ticketId}/inventories` ist dieser flache Attribut-Dump
        direkt (identisch zu `TicketInventoryLinkAttributes`).
      allOf:
        - $ref: '#/components/schemas/TicketInventoryLinkAttributes'

    # ── Ticket Metadata Catalogs ────────────────────
    TicketMetadataResource:
      type: object
      description: |
        Generischer flacher Zeilen-Dump (kein `id`/`type`/`attributes`-Wrapper),
        gemeinsam genutzt fuer die read-only Kataloge `ticket-types`,
        `ticket-categories`, `ticket-priorities`, `ticket-versions` und
        `ticket-milestones`. `id`/`title`/`alias` sind allen fuenf gemeinsam,
        die uebrigen Felder unterscheiden sich je Katalog (siehe jeweilige
        Endpunkt-Beschreibung).
      properties:
        id:
          type: integer
          example: 1
        title:
          type: string
          example: "Bug"
        alias:
          type: string
          example: "bug"
      additionalProperties: true

    TicketStatusResource:
      type: object
      description: |
        Mapping der geteilten `status`-Tabelle (type=`support_tickets`) auf ein
        eigenes flaches Schema (kein `id`/`type`/`attributes`-Wrapper) —
        abweichend von `TicketMetadataResource`.
      properties:
        id:
          type: string
          format: uuid
          description: "= status.uID"
          example: "c6f35048-6556-5dba-602c-10eed9cb2473"
        title:
          type: string
          example: "Neu"
        background_color:
          type: string
          description: "= status.color"
          example: "#CE3535"
        color:
          type: string
          description: "= status.text_color"
          example: "#FFFFFF"

    # ── Support Project ─────────────────────────────
    ProjectAttributes:
      type: object
      properties:
        title:
          type: string
          example: "Kalibrierlabor Migration"
        alias:
          type: string
          description: Auto-Slug aus `title`, wenn beim Anlegen nicht angegeben
          example: "kalibrierlabor-migration"
        description:
          type: string
          nullable: true
          example: "A test project"
        status:
          type: integer
          enum: [0, 1]
          description: "0 = inaktiv, 1 = aktiv"
          example: 1
        created_at:
          type: string
          example: "2026-08-03 09:00:00"
        updated_at:
          type: string
          nullable: true
          example: "2026-08-03 09:40:00"

    ProjectResource:
      type: object
      properties:
        id:
          type: integer
          example: 4
        type:
          type: string
          enum: [project]
        attributes:
          $ref: '#/components/schemas/ProjectAttributes'
        relationships:
          type: object
          description: "`creator` ist immer geladen."
          properties:
            creator:
              type: object
              nullable: true
              properties:
                id: { type: string, format: uuid }
                type: { type: string, enum: [user] }
                name: { type: string }

    ProjectCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ProjectResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
        links:
          type: object
          properties:
            self:
              type: string
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    ProjectInput:
      type: object
      required: [title]
      properties:
        title:
          type: string
          maxLength: 255
          example: "Kalibrierlabor Migration"
        alias:
          type: string
          maxLength: 250
          description: Auto-slugifiziert aus `title`, wenn nicht angegeben
        description:
          type: string
          nullable: true
          example: "A test project"
        status:
          type: integer
          enum: [0, 1]
          default: 1
          description: "0 = inaktiv, 1 = aktiv (Standard)"

    ProjectUpdateInput:
      type: object
      description: |
        Partial Update (`sometimes`) — nur uebergebene Felder werden geaendert.
        Kein Auto-Slug- oder Default-Status-Fallback wie bei `ProjectInput`.
      properties:
        title:
          type: string
          maxLength: 255
        alias:
          type: string
          maxLength: 250
        description:
          type: string
          nullable: true
        status:
          type: integer
          enum: [0, 1]
    # ── Inventory sub-resources / actions (statistics, tree, status, bulk, import) ──
    InventoryStatisticsResource:
      type: object
      description: |
        Antwortobjekt von `GET /inventories/statistics` — FLACH, kein
        `data`-Wrapper, keine JSON:API-Huelle (kein id/type/attributes).
      properties:
        total:
          type: integer
          description: Gesamtzahl aller Inventarobjekte
          example: 812
        overdue_count:
          type: integer
          description: "Anzahl Objekte mit ueberfaelliger Kalibrierung (`next_calibration_date` in der Vergangenheit)"
          example: 14
        due_count:
          type: integer
          description: Anzahl Objekte mit demnaechst faelliger Kalibrierung
          example: 37
        calibration_total:
          type: integer
          description: Gesamtzahl aller Kalibrierungen
          example: 2140

    InventoryDueCountsResource:
      type: object
      description: |
        Antwortobjekt von `GET /inventories/due-counts` — FLACH, kein
        `data`-Wrapper, keine JSON:API-Huelle. Ein Schluessel je Faelligkeits-
        Topf, gezaehlt in der Sichtbarkeit des Benutzers (siehe `scope`). Ein
        per Schwelle 0 abgeschalteter Topf zaehlt 0.
      properties:
        overdue_count:
          type: integer
          description: "Anzahl Geraete mit ueberfaelliger Kalibrierung (`next_calibration_date` in der Vergangenheit)"
          example: 14
        due_count:
          type: integer
          description: Anzahl Geraete mit demnaechst faelliger Kalibrierung
          example: 37
        critical_1_count:
          type: integer
          description: Anzahl Geraete im Fenster der Schwelle `critical_date_period_1`
          example: 21
        critical_2_count:
          type: integer
          description: Anzahl Geraete im Fenster der Schwelle `critical_date_period_2`
          example: 9
        critical_3_count:
          type: integer
          description: Anzahl Geraete im Fenster der Schwelle `critical_date_period_3`
          example: 4

    InventoryTreeNodeResource:
      type: object
      description: |
        Id-Skelett eines Knotens in `meta.tree` von `GET /inventories/tree`
        (siehe `InventoryTreeService::buildSkeleton()`). Traegt bewusst nur
        Flags plus `id` — die eigentlichen Attribute stehen in `data`, ueber
        `id` verknuepfbar. Rekursiv ueber `children`.
      properties:
        id:
          type: string
          description: MTAG (UUID) des Knotens
          example: "b6a1c2d3-4567-8901-abcd-ef1234567890"
        is_match:
          type: boolean
          description: "Treffer des aktiven Filters (immer `true` im ungefilterten Browse-Modus)"
        is_pinned:
          type: boolean
          description: Ueber `?pinned=` explizit angeheftet
        has_children:
          type: boolean
          description: "Hat (nach Filterung) sichtbare Kind-Knoten — steuert den gerenderten Zweig"
        child_count:
          type: integer
          description: Ungefilterte Gesamtzahl direkter Kinder des Geraets
        is_container:
          type: boolean
          description: Als Container/Baugruppe markiert (kann dies auch ohne Kinder sein)
        children:
          type: array
          description: Direkte Kind-Knoten, rekursiv (max. Tiefe 20)
          items:
            $ref: '#/components/schemas/InventoryTreeNodeResource'

    InventoryHierarchyResource:
      type: object
      description: "`data`-Objekt von `GET /inventories/{mtag}/hierarchy` (kein `meta`)."
      properties:
        id:
          type: string
          format: uuid
          description: MTAG des angefragten Inventarobjekts
        ancestors:
          type: array
          description: Elternkette, wurzel-zuerst (root-first), max. Tiefe 20
          items:
            $ref: '#/components/schemas/InventoryResource'
        children:
          type: array
          description: Direkte Kinder (nicht rekursiv)
          items:
            $ref: '#/components/schemas/InventoryResource'

    InventoryTransitionMeta:
      type: object
      description: |
        `meta.transition` einer erfolgreichen Statustransition
        (`POST /inventories/{mtag}/status`,
        `POST /inventories/{mtag}/additional-fields`).
      properties:
        success:
          type: boolean
          example: true
        applied_defaults:
          type: object
          additionalProperties: true
          description: Angewendete Default-Werte (Spalte zu Wert) aus DefaultValue-Regeln des Zielstatus
        cleared_fields:
          type: array
          description: Spalten, die durch eine EmptyNow-Regel geleert wurden
          items:
            type: string
        counter_updated:
          type: integer
          nullable: true
          description: "Neuer Zaehlerwert, wenn eine `{STATUS_COUNT}`-Vorlage einen Inkrement ausgeloest hat, sonst `null`"
        reload_required:
          type: boolean
          description: "`true`, wenn EmptyNow/DefaultValue Attribute geaendert haben, die der Client evtl. gecached hat"
        follow_up_actions:
          type: array
          description: "`status_action.type`-Werte, fuer die ein Folge-Ereignis ausgeloest wurde (z.B. Buchung/Standort/Notiz anlegen)"
          items:
            type: string

    InventoryStatusBatchResultResource:
      type: object
      description: |
        Einzelergebnis in `data[]` von `POST /inventories/status-batch` — ein
        einfaches Ergebnisobjekt (KEIN JSON:API `type`/`attributes`). Je nach
        `success` sind unterschiedliche Felder gesetzt.
      properties:
        id:
          type: string
          description: Angefragte Inventar-Id (identisch zum Request, auch bei Fehlschlag)
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        success:
          type: boolean
        transition:
          type: object
          description: Nur vorhanden, wenn `success` true ist
          properties:
            applied_defaults:
              type: object
              additionalProperties: true
            cleared_fields:
              type: array
              items:
                type: string
            counter_updated:
              type: integer
              nullable: true
        error:
          type: string
          nullable: true
          description: |
            Nur vorhanden, wenn `success` false ist: `"Inventory not found."`
            (Id existiert nicht oder liegt ausserhalb der Sichtbarkeit — kein
            Unterschied, um IDOR zu vermeiden) oder `"Mandatory fields missing."`
          example: "Mandatory fields missing."
        required_fields:
          type: array
          nullable: true
          description: 'Nur vorhanden bei `error = "Mandatory fields missing."`'
          items:
            type: string

    InventoryBulkPreviewResource:
      type: object
      description: |
        `data`-Objekt von `POST /inventories/bulk-preview` — synthetische
        Ressource ohne `id` (kein persistiertes Objekt).
      properties:
        type:
          type: string
          enum: [inventory-bulk-preview]
        attributes:
          type: object
          properties:
            items:
              type: array
              items:
                type: object
                properties:
                  serial_number:
                    type: string
                    example: "SN-00100"
                  duplicate:
                    type: boolean
                    description: "`true`, wenn diese Seriennummer bereits existiert"
            total:
              type: integer
            duplicates_total:
              type: integer

    InventoryBulkResultResource:
      type: object
      description: |
        Vereinfachtes Item in `data[]` von `POST /inventories/bulk` — nur
        `toApiArray()`-Rohattribute, OHNE `relationships` und ohne
        aufgeloeste `*_label`-Felder (anders als `InventoryResource`).
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [inventory]
        attributes:
          type: object
          additionalProperties: true
          description: Rohattribute wie in `InventoryAttributes`, ohne Relationships/Labels

    InventoryScanResolveResource:
      type: object
      description: |
        `data`-Objekt von `POST /inventories/scan-resolve` — synthetische
        Ressource ohne `id`.
      properties:
        type:
          type: string
          enum: [inventory-scan-resolve]
        attributes:
          type: object
          properties:
            items:
              type: array
              description: |
                Ein Eintrag je (deduplizierter) Code, in Scan-Reihenfolge.
                `inventory` ist nur gesetzt, wenn `found` true ist.
              items:
                type: object
                properties:
                  code:
                    type: string
                    example: "DMM-001"
                  found:
                    type: boolean
                  inventory:
                    $ref: '#/components/schemas/InventoryResource'
            found_total:
              type: integer
            not_found_total:
              type: integer

    InventoryImportPreviewResource:
      type: object
      description: |
        `data`-Objekt von `POST /inventories/import-preview` — synthetische
        Ressource ohne `id`.
      properties:
        type:
          type: string
          enum: [inventory-import-preview]
        attributes:
          type: object
          properties:
            columns:
              type: array
              description: Aufgeloestes Spalten-Mapping (Datei-Header zu Zielentitaet/api_name)
              items:
                type: object
                properties:
                  header:
                    type: string
                    example: "Seriennummer"
                  target:
                    type: string
                    nullable: true
                    description: "Zielentitaet (`inventory`, `customer`, `calibration`, `category`, ...) oder `null`, wenn die Spalte ignoriert wird"
                    example: inventory
                  api_name:
                    type: string
                    nullable: true
                    example: serial_number
            unmapped_headers:
              type: array
              description: Datei-Header ohne aufgeloestes Mapping
              items:
                type: string
            rows:
              type: array
              description: Vorschau-Zeilen, IMMER auf max. 50 gekappt (unabhaengig von `total`)
              items:
                type: object
                properties:
                  values:
                    type: object
                    additionalProperties: true
                    description: "`entity:api_name` zu Rohwert aus der Datei"
                  asset_number:
                    type: string
                    nullable: true
                  exists:
                    type: boolean
                    description: Ob die Inventarnummer bereits existiert (→ Update statt Neuanlage)
                  importable:
                    type: boolean
                    description: "`false`, wenn die Zeile weder einen Inventar- noch einen Kunden-Schluessel liefert"
            total:
              type: integer
              description: Gesamtzahl der Datenzeilen in der Datei
            shown:
              type: integer
              description: "Anzahl in `rows` enthaltener Zeilen (<= 50)"
            new:
              type: integer
            update:
              type: integer
            skipped:
              type: integer
            truncated:
              type: boolean
              description: "`true`, wenn `total` > `shown`"

    InventoryImportResultResource:
      type: object
      description: |
        `data`-Objekt von `POST /inventories/import` — synthetische Ressource
        ohne `id`.
      properties:
        type:
          type: string
          enum: [inventory-import-result]
        attributes:
          type: object
          properties:
            customers_created:
              type: integer
            customers_updated:
              type: integer
            created:
              type: integer
              description: Neu angelegte Inventarobjekte
            updated:
              type: integer
              description: "Aktualisierte Inventarobjekte (nur bei `update_existing: true`)"
            calibrations_created:
              type: integer
            calibrations_skipped:
              type: integer
            categories_linked:
              type: integer
            skipped:
              type: integer
              description: Zeilen ohne verwertbaren Schluessel oder ohne Schreibwirkung
            skipped_rows:
              type: array
              description: "1-basierte Zeilennummern uebersprungener Zeilen, max. 100 Eintraege"
              items:
                type: integer
    # ── Notepad ────────────────────────────────────
    NotepadAttributes:
      type: object
      description: |
        `array_merge(toApiArray(), relationLabelValues(), statusLabelValues())`.
        `{feld}_label`-Werte sind aufgeloeste Anzeigewerte, `null` wenn die
        Referenz leer oder nicht aufloesbar ist.
      properties:
        comment:
          type: string
          nullable: true
          example: "Kalibrierung fällig"
        inventory_id:
          type: string
          nullable: true
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        customer_id:
          type: string
          nullable: true
          example: "00000000-0000-4000-a000-000000000001"
        contact_id:
          type: string
          nullable: true
        assigned_to:
          type: string
          nullable: true
          description: User-UUID
          example: "45f09720-082f-5c74-9cfa-a605e2863904"
        assigned_to_group:
          type: integer
          nullable: true
          example: 5
        repair_id:
          type: string
          nullable: true
          description: FK zu einer Reparatur (RTAG) — gesetzt macht den Eintrag zur Wartungserinnerung
          example: "11111111-1111-4111-a111-111111111111"
        entry_type:
          type: string
          enum: [task, maintenance_reminder]
          description: "Abgeleitet: gesetzt via `repair_id` = `maintenance_reminder`, sonst `task`"
        is_maintenance_reminder:
          type: boolean
          description: "Abgeleitet, aequivalent zu `entry_type === maintenance_reminder`"
        date_time:
          type: string
          nullable: true
          description: "Roh-Zeichenkette `YYYY-MM-DD HH:MM:SS`, KEIN ISO8601"
          example: "2026-02-28 10:00:00"
        status:
          type: string
          nullable: true
          description: |
            Zwei Formen kommen im Datenbestand vor: ein einfacher Literal wie
            `new`, oder eine Status-uID wie `test-status-notepad-done`.
          example: "new"
        send_email:
          type: integer
          nullable: true
          enum: [0, 1]
        reminder_at:
          type: string
          nullable: true
        created_by:
          type: string
          nullable: true
          description: User-UUID des Erstellers
          example: "45f09720-082f-5c74-9cfa-a605e2863904"
        modified_by:
          type: string
          nullable: true
        modified_at:
          type: integer
          nullable: true
          description: Unix-Zeitstempel (kein Datumsstring)
          example: 1769958000
        inventory_id_label:
          type: string
          nullable: true
        customer_id_label:
          type: string
          nullable: true
        contact_id_label:
          type: string
          nullable: true
        assigned_to_label:
          type: string
          nullable: true
        created_by_label:
          type: string
          nullable: true
        modified_by_label:
          type: string
          nullable: true
        repair_id_label:
          type: string
          nullable: true
        status_label:
          type: string
          nullable: true

    NotepadResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [notepad]
        attributes:
          $ref: '#/components/schemas/NotepadAttributes'
        relationships:
          type: object
          description: |
            `assigned_group`/`additional_recipients` sind NUR bei
            show/update/store/`PUT .../recipients` befuellt. Bei
            `changeStatus`, `saveAdditionalFields` und allen Index-Endpunkten
            liefert Laravels `whenLoaded()` stattdessen ein leeres Array
            `[]` statt `{"data": ...}`.
          properties:
            assigned_group:
              type: object
              properties:
                data:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: string
                    type:
                      type: string
                      enum: [group]
                    name:
                      type: string
            additional_recipients:
              type: object
              properties:
                data:
                  type: array
                  items:
                    type: object
                    properties:
                      id:
                        type: string
                      type:
                        type: string
                        enum: [user]
                      name:
                        type: string

    NotepadCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/NotepadResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer
            api_version:
              type: string
        links:
          type: object
          properties:
            self:
              type: string
              nullable: true
            first:
              type: string
              nullable: true
            last:
              type: string
              nullable: true
            next:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true

    NotepadInput:
      type: object
      required: [comment]
      properties:
        comment:
          type: string
          maxLength: 2048
          description: Pflichtfeld
          example: "Kalibrierung fällig"
        inventory_id:
          type: string
          maxLength: 80
          nullable: true
        customer_id:
          type: string
          maxLength: 80
          nullable: true
        contact_id:
          type: string
          maxLength: 36
          nullable: true
        assigned_to:
          type: string
          maxLength: 36
          nullable: true
          description: User-UUID
        assigned_to_group:
          type: integer
          nullable: true
        additional_recipients:
          type: array
          items:
            type: string
            maxLength: 36
          description: User-UUIDs, zusaetzlich zu `assigned_to` benachrichtigt
        repair_id:
          type: string
          maxLength: 80
          nullable: true
        date_time:
          type: string
          nullable: true
          description: "Standard: jetzt. Format `YYYY-MM-DD HH:MM:SS`."
        status:
          type: string
          maxLength: 100
          nullable: true
          description: "Standard: `new`"
        reminder_at:
          type: string
          nullable: true
        send_email:
          type: boolean
          default: false
          description: |
            Standard `false`. V1 hatte hier Standard `true` — bewusste
            V2-Abweichung, damit bestehende API-Clients nicht ungewollt
            E-Mails ausloesen.

    NotepadUpdateInput:
      type: object
      description: |
        Partial-Update, alle Felder optional. Ein explizites `null` loescht
        das Feld — Unterschied zu Store: bei `inventory_id`/`customer_id`
        wird dabei auf V1s `''`-Konvention zurueckgesetzt (Response zeigt
        danach `""`, NICHT `null`!); bei `contact_id`/`assigned_to`/
        `repair_id`/`date_time`/`reminder_at`/`status` wird echtes `null`
        gespeichert. Fehlt ein Feld im Body komplett, bleibt der bestehende
        Wert unveraendert.
      properties:
        comment:
          type: string
          maxLength: 2048
        inventory_id:
          type: string
          maxLength: 80
          nullable: true
        customer_id:
          type: string
          maxLength: 80
          nullable: true
        contact_id:
          type: string
          maxLength: 36
          nullable: true
        assigned_to:
          type: string
          maxLength: 36
          nullable: true
        assigned_to_group:
          type: integer
          nullable: true
        additional_recipients:
          type: array
          items:
            type: string
            maxLength: 36
        repair_id:
          type: string
          maxLength: 80
          nullable: true
        date_time:
          type: string
          nullable: true
        status:
          type: string
          maxLength: 100
          nullable: true
        reminder_at:
          type: string
          nullable: true
        send_email:
          type: boolean
          description: Bei `true` UND getrackten Feldaenderungen wird eine Update-Benachrichtigung verschickt (Seiteneffekt).

    NotepadCalendarEventAttributes:
      type: object
      description: |
        Flaches Kalender-Event-Format, genutzt von `PUT /notepads/{id}/calendar`,
        `PATCH /notepads/{id}/date` und `POST /notepads/calendar`. Beachte:
        `GET /notepads/calendar` liefert ein AEHNLICHES, aber eigenes flaches
        Format ohne `type`/`attributes`-Huelle und ohne `customer_id`/
        `contact_id` (siehe dortige Pfadbeschreibung) — dieses Schema gilt
        NICHT fuer diesen Endpunkt.
      properties:
        title:
          type: string
          nullable: true
          description: entspricht `comment`
        date:
          type: string
          nullable: true
          description: entspricht `date_time` (roh, kein ISO8601)
        status:
          type: string
          nullable: true
        assigned_to:
          type: string
          nullable: true
        inventory_id:
          type: string
          nullable: true
        customer_id:
          type: string
          nullable: true
        contact_id:
          type: string
          nullable: true

    NotepadCalendarEventResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [calendar-event]
        attributes:
          $ref: '#/components/schemas/NotepadCalendarEventAttributes'

    # ── Document Name Check ───────────────────────
    DocumentNameCheckAttributes:
      type: object
      properties:
        has_pattern:
          type: boolean
          description: Ob die Dokumentart (`file_setting`) ein Namensmuster definiert
        pattern:
          type: string
          nullable: true
          example: "{asset_number}{#ANY#}"
        matches:
          type: boolean
          description: Ob `filename` das Muster erfuellt
        link_table:
          type: string
          nullable: true
          description: Entitaetstabelle der Dokumentart (`file_setting.type`)
          example: inventory
        link_id:
          type: string
          nullable: true
          description: UUID des eindeutig getroffenen Zieldatensatzes (nur bei genau einem Treffer)
          example: "ea79b775-8b4b-8e17-98c9-b33e7065ff6f"
        expected_type:
          type: string
          nullable: true
          description: Dokumentart-Code (`file_setting.column_name`)
          example: check_cert
        reason:
          type: string
          nullable: true
          description: |
            Nur bei `matches:false` gesetzt. Entweder `"Muster erkannt, aber
            kein passender Datensatz ({type}) gefunden."` (Regex passt, aber
            kein Treffer in der Zieltabelle) oder der generische Fallback
            `"Der Dateiname entspricht nicht dem Zuordnungsmuster."` (Regex
            passt gar nicht auf den Dateinamen).

    DocumentNameCheckResource:
      type: object
      description: "Abweichend von den uebrigen Resources: KEIN `id`-Feld."
      properties:
        type:
          type: string
          enum: [document-name-check]
        attributes:
          $ref: '#/components/schemas/DocumentNameCheckAttributes'
    # ── Repair ─────────────────────────────────────
    RepairAttributes:
      type: object
      description: |
        Repair-Attribute inkl. tenant-spezifischer Zusatzfelder (`HasDynamicFields`,
        nach `api_name` abgeflacht in die Top-Level-Attribute).
      properties:
        inventory_id:
          type: string
          format: uuid
          description: MTAG (UUID) des zugehoerigen Inventarobjekts
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        description:
          type: string
          nullable: true
          example: "Kalibrierung fehlgeschlagen - Reparatur"
        notes:
          type: string
          nullable: true
          example: "Displayfehler behoben"
        repair_date:
          type: string
          nullable: true
          example: "2026-07-01 00:00:00"
        repair_time:
          type: string
          nullable: true
          example: "08:00:00"
        status_code:
          type: string
          nullable: true
          description: Einzelnes Statuszeichen, z.B. `A`/`C`
          example: "A"
        status:
          type: string
          nullable: true
          description: Freitext-/Workflow-Status (unabhaengig von `status_code`)
        data_source:
          type: string
          nullable: true
          description: "Herkunft des Datensatzes, z.B. `cal` (aus Kalibrierung), `booking`"
          example: cal
        custom_fields:
          type: object
          additionalProperties: true
        inventory_id_label:
          type: string
          nullable: true
          example: "DMM-001"
        status_label:
          type: string
          nullable: true
          example: "Aktiv"
        category_ids:
          type: array
          items:
            type: string
            format: uuid
      additionalProperties: true

    RepairResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID der Reparatur
        type:
          type: string
          enum: [repair]
        attributes:
          $ref: '#/components/schemas/RepairAttributes'
        relationships:
          type: object
          description: |
            `inventory` ist nur bei `GET /repairs/{rtag}` befuellt — die
            Listenansichten (`GET /repairs`, `GET /repairs/export`) liefern
            hier `data: null`, da dort nicht eager-geladen wird.
          properties:
            inventory:
              type: object
              properties:
                data:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: string
                    type:
                      type: string
                      enum: [inventory]

    RepairCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/RepairResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer

    RepairInput:
      type: object
      description: "`StoreRepairRequest` — `id` (UUID) wird beim Anlegen automatisch generiert."
      required: [inventory_id]
      properties:
        inventory_id:
          type: string
          maxLength: 80
          description: Pflichtfeld, unveraenderlich nach dem Anlegen
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        description:
          type: string
          nullable: true
          maxLength: 102
          example: "Kalibrierung fehlgeschlagen - Reparatur"
        notes:
          type: string
          nullable: true
          maxLength: 102
          example: "Displayfehler behoben"
        repair_date:
          type: string
          nullable: true
          maxLength: 255
          example: "2026-07-01 00:00:00"
        repair_time:
          type: string
          nullable: true
          maxLength: 8
          example: "08:00:00"
        status_code:
          type: string
          nullable: true
          maxLength: 1
          example: "A"
        status:
          type: string
          nullable: true
          maxLength: 80
        category_ids:
          type: array
          description: Weist die Reparatur Kategorien vom Typ `repair` zu (kein Spaltenfeld)
          items:
            type: string
            format: uuid
      additionalProperties: true

    RepairUpdateInput:
      type: object
      description: |
        `UpdateRepairRequest` — wie `RepairInput`, aber ohne `inventory_id`
        (unveraenderlich) und alle Felder optional. Ein hier enthaltenes,
        geaendertes `status` durchlaeuft denselben StatusTransitionService wie
        `POST /repairs/{rtag}/status`.
      properties:
        description:
          type: string
          nullable: true
          maxLength: 102
        notes:
          type: string
          nullable: true
          maxLength: 102
        repair_date:
          type: string
          nullable: true
          maxLength: 255
        repair_time:
          type: string
          nullable: true
          maxLength: 8
        status_code:
          type: string
          nullable: true
          maxLength: 1
        status:
          type: string
          nullable: true
          maxLength: 80
        category_ids:
          type: array
          items:
            type: string
            format: uuid
      additionalProperties: true

    # ── Booking ────────────────────────────────────
    BookingAttributes:
      type: object
      description: |
        Booking-Attribute inkl. tenant-spezifischer Zusatzfelder
        (`HasDynamicFields`, nach `api_name` abgeflacht).
      properties:
        customer_id:
          type: string
          format: uuid
          nullable: true
          example: "00000000-0000-4000-a000-000000000001"
        number:
          type: string
          nullable: true
          example: "ORD-2026-001"
        date:
          type: string
          nullable: true
          example: "2026-02-28"
        status:
          type: string
          nullable: true
          example: offer
        comment:
          type: string
          nullable: true
          example: "Test booking"
        delivery_condition:
          type: string
          nullable: true
          example: "DAP Werk"
        delivery_costs:
          type: number
          nullable: true
          example: 0
        customer_contact_id:
          type: string
          nullable: true
        delivery_contact_id:
          type: string
          nullable: true
        invoice_contact_id:
          type: string
          nullable: true
        delivery_customer_id:
          type: string
          nullable: true
        invoice_customer_id:
          type: string
          nullable: true
        modified:
          type: integer
          nullable: true
          description: Unix-Zeitstempel der letzten Aenderung
          example: 1772486400
        modified_by:
          type: string
          format: uuid
          nullable: true
        custom_fields:
          type: object
          additionalProperties: true
        customer_id_label:
          type: string
          nullable: true
          example: "Test GmbH"
        status_label:
          type: string
          nullable: true
          example: "Angebot"
        category_ids:
          type: array
          items:
            type: string
            format: uuid
      additionalProperties: true

    BookingResource:
      type: object
      description: "Enthaelt bewusst **kein** `relationships`-Objekt (im Unterschied zu `RepairResource`)."
      properties:
        id:
          type: string
          format: uuid
          description: UUID des Auftrags
        type:
          type: string
          enum: [booking]
        attributes:
          $ref: '#/components/schemas/BookingAttributes'

    BookingCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/BookingResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer

    BookingInput:
      type: object
      description: |
        `StoreBookingRequest`. Wird auch fuer `PUT /bookings/{id}` (Partial
        Update, kein dediziertes FormRequest) sowie als Basis fuer
        `create-with-inventories`/`create-from-notepad` wiederverwendet — bei
        Partial Updates muessen nur die tatsaechlich zu aendernden Felder
        gesendet werden.
      required: [customer_id, status]
      properties:
        customer_id:
          type: string
          maxLength: 80
          example: "00000000-0000-4000-a000-000000000001"
        number:
          type: string
          nullable: true
          maxLength: 255
          description: Nur eindeutigkeitspflichtig, wenn per Property-Einstellung aktiviert
          example: "ORD-2026-003"
        date:
          type: string
          nullable: true
          maxLength: 10
          example: "2026-03-01"
        status:
          type: string
          maxLength: 100
          example: offer
        comment:
          type: string
          nullable: true
        delivery_condition:
          type: string
          nullable: true
          maxLength: 255
          example: "DAP Werk"
        delivery_costs:
          type: number
          nullable: true
          example: 15.00
        customer_contact_id:
          type: string
          nullable: true
          maxLength: 36
        delivery_contact_id:
          type: string
          nullable: true
          maxLength: 36
        invoice_contact_id:
          type: string
          nullable: true
          maxLength: 36
        delivery_customer_id:
          type: string
          nullable: true
          maxLength: 80
        invoice_customer_id:
          type: string
          nullable: true
          maxLength: 80
      additionalProperties: true

    # ── Article (Auftragsposition) ────────────────
    ArticleAttributes:
      type: object
      description: |
        Enthaelt zusaetzlich zu den unten aufgefuehrten Feldern dynamische,
        tenant-spezifische Positionsfelder (A4301..A4320-Stil).
      properties:
        booking_id:
          type: string
          format: uuid
        article_id:
          type: string
          description: "Freier Artikel-/Typ-Code (kein FK-Constraint), z.B. `test-article-1`"
          example: "test-article-1"
        article_type:
          type: string
          enum: [inventory, article, type]
          nullable: true
        complexity:
          type: string
          nullable: true
          example: "DAkkS-Kalibrierung"
        quantity:
          type: number
          example: 1
        price:
          type: number
          example: 89.5
        tax:
          type: number
          example: 19
        unit:
          type: string
          nullable: true
          example: "EA"
        discount:
          type: number
          example: 0
        costs:
          type: number
          example: 0
        delivery_costs:
          type: number
          nullable: true
          example: 0
        pos_number:
          type: number
          nullable: true
          example: 1
        delivery_part:
          type: integer
          nullable: true
        delivery_date:
          type: string
          nullable: true
          example: "2026-03-05"
        billing_part:
          type: integer
          nullable: true
        billing_date:
          type: string
          nullable: true
        calibration_type:
          type: string
          nullable: true
        comment:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
          example: "Individuelle Bezeichnung"
        article_id_label:
          type: string
          nullable: true
          description: "Fehlt/`null` bei ueber `POST .../standard-articles` erstellten Positionen"
          example: "Kalibrierung Standard"
      additionalProperties: true

    ArticleResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: UUID der Artikelposition
        type:
          type: string
          enum: [article]
        attributes:
          $ref: '#/components/schemas/ArticleAttributes'

    ArticleInput:
      type: object
      description: |
        Inline-Validierung (kein FormRequest), identische Regeln fuer
        `POST /bookings/{bookingId}/articles` und `PUT /articles/{id}` —
        `article_id` ist nur beim Anlegen Pflicht, bei Partial Updates
        optional.
      required: [article_id]
      properties:
        article_id:
          type: string
          maxLength: 80
          example: "test-article-1"
        article_type:
          type: string
          nullable: true
          maxLength: 20
          enum: [inventory, article, type]
          example: article
        complexity:
          type: string
          nullable: true
          maxLength: 255
          example: "DAkkS-Kalibrierung"
        quantity:
          type: number
          example: 1
        price:
          type: number
          example: 89.5
        tax:
          type: number
          example: 19
        unit:
          type: string
          nullable: true
          maxLength: 10
          example: "EA"
        discount:
          type: number
          example: 0
        costs:
          type: number
          example: 0
        delivery_costs:
          type: number
          nullable: true
          example: 0
        pos_number:
          type: number
          nullable: true
          example: 1
        delivery_part:
          type: integer
          nullable: true
        billing_part:
          type: integer
          nullable: true
        delivery_date:
          type: string
          nullable: true
          example: "2026-03-05"
        billing_date:
          type: string
          nullable: true
        calibration_type:
          type: string
          nullable: true
          maxLength: 255
        comment:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
          maxLength: 255
          example: "Individuelle Bezeichnung"

    # ── Price ──────────────────────────────────────
    PriceAttributes:
      type: object
      description: |
        Price-Attribute inkl. tenant-spezifischer Zusatzfelder
        (`HasDynamicFields`). Primaerschluessel des Modells ist `pID`
        (nicht `id`) — als String modelliert, keine UUID-Pflicht.
      properties:
        type_id:
          type: string
          example: "test-type-multimeter"
        category_id:
          type: string
          example: "test-price-cat-1"
        price:
          type: number
          example: 120
        complexity:
          type: string
          nullable: true
        price_min:
          type: number
          nullable: true
          example: 0
        price_max:
          type: number
          nullable: true
          example: 0
        start_date:
          type: string
          nullable: true
        end_date:
          type: string
          nullable: true
        costs:
          type: number
          nullable: true
          example: 0
        quantity:
          type: number
          nullable: true
          example: 1
        unit:
          type: string
          nullable: true
          example: "EA"
        discount:
          type: number
          nullable: true
          example: 0
        tax:
          type: number
          nullable: true
          example: 19
        created_by:
          type: string
          format: uuid
          nullable: true
        modified_by:
          type: string
          format: uuid
          nullable: true
        modified_at:
          type: string
          format: date-time
          nullable: true
        type_id_label:
          type: string
          nullable: true
          example: "Fluke 87V"
        category_id_label:
          type: string
          nullable: true
          example: "Standard Preise"
        created_by_label:
          type: string
          nullable: true
          example: "Test Admin"
      additionalProperties: true

    PriceResource:
      type: object
      properties:
        id:
          type: string
          description: "`pID` des Preises"
          example: "test-price-1"
        type:
          type: string
          enum: [price]
        attributes:
          $ref: '#/components/schemas/PriceAttributes'

    PriceCollection:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/PriceResource'
        meta:
          type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            per_page:
              type: integer
            last_page:
              type: integer

    PriceInput:
      type: object
      description: |
        `StorePriceRequest`. Beim Update (`PUT /prices/{id}`) sind alle Felder
        optional (inkl. `type_id`/`category_id`, beide neu zuweisbar); beim
        Duplizieren (`POST /prices/{id}/duplicate`) gilt derselbe Schema-Body
        als optionale Teilmenge — nicht uebergebene Felder werden von der
        Quelle kopiert. Wird von `ValidatesPriceConsistency` geprueft
        (Eindeutigkeit, Datumslogik, Min/Max).
      required: [type_id, category_id]
      properties:
        type_id:
          type: string
          maxLength: 80
          example: "test-type-torque"
        category_id:
          type: string
          maxLength: 80
          example: "test-price-cat-2"
        price:
          type: number
          minimum: 0
          example: 200.00
        complexity:
          type: string
          nullable: true
          maxLength: 255
        price_min:
          type: number
          minimum: 0
        price_max:
          type: number
          minimum: 0
        start_date:
          type: string
          nullable: true
          example: "2027-01-01"
        end_date:
          type: string
          nullable: true
          example: "2027-01-01"
        costs:
          type: number
          minimum: 0
        quantity:
          type: number
          minimum: 0
          example: 0.5
        unit:
          type: string
          nullable: true
          maxLength: 10
        discount:
          type: number
          minimum: 0
          maximum: 100
        tax:
          type: number
          minimum: 0
      additionalProperties: true

    # ── Price Category ─────────────────────────────
    PriceCategoryAttributes:
      type: object
      properties:
        name:
          type: string
          example: "Standard Preise"
        description:
          type: string
          nullable: true
          example: "Standard Preisgruppe"
        created_by:
          type: string
          format: uuid
          nullable: true
        modified_by:
          type: string
          format: uuid
          nullable: true
        modified_at:
          type: string
          format: date-time
          nullable: true

    # ── Price Class (Preiskategorie, AP 11) ────────
    PriceClassResource:
      type: object
      properties:
        id:
          type: string
          description: UUID der Preiskategorie
        type:
          type: string
          enum: [price-class]
        attributes:
          $ref: '#/components/schemas/PriceClassAttributes'

    PriceClassAttributes:
      type: object
      properties:
        name:
          type: string
          example: "Druck relativ 0-10 bar"
        number:
          type: string
          nullable: true
          description: >-
            Die fachliche Nummer aus dem Bestand (Fachgebiet.Gattung.Stufe).
            Eindeutig unter den Kategorien, die eine tragen.
          example: "100.110.01"
        parent_id:
          type: string
          nullable: true
          description: Die Elternkategorie; leer = oberste Ebene
        parent_id_label:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        sort_order:
          type: integer
          description: Anzeigereihenfolge innerhalb einer Ebene
        members_count:
          type: integer
          description: >-
            Zahl der Geraetetypen und Prozeduren in dieser Kategorie. Nur bei
            `index` und `show` enthalten; nach `store`/`update` fehlt der
            Schluessel, statt eine 0 zu behaupten, die niemand gezaehlt hat.
            Eine Kategorie mit 0 Mitgliedern traegt zwar einen Preis, findet
            aber nie einen Geraetetyp.
          example: 12
        children_count:
          type: integer
          description: >-
            Zahl der direkten Unterkategorien. Wie `members_count` nur bei
            `index` und `show` enthalten.
          example: 3

    PriceClassInput:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 255
        number:
          type: string
          maxLength: 80
          nullable: true
        parent_id:
          type: string
          maxLength: 80
          nullable: true
        description:
          type: string
          maxLength: 2000
          nullable: true
        sort_order:
          type: integer
          minimum: 0
          maximum: 100000

    PriceClassMemberResource:
      type: object
      properties:
        id:
          type: string
        type:
          type: string
          enum: [price-class-member]
        attributes:
          type: object
          properties:
            price_class_id:
              type: string
            member_table:
              type: string
              enum: [type, procedure]
            member_id:
              type: string
              description: "`types.tID` bzw. `procedures.uID`"
            member_id_label:
              type: string
              nullable: true

    PriceClassMemberInput:
      type: object
      required: [member_table, member_id]
      properties:
        member_table:
          type: string
          enum: [type, procedure]
        member_id:
          type: string
          maxLength: 80

    ServicePriceResource:
      type: object
      properties:
        id:
          type: string
          description: UUID der Zeile
        type:
          type: string
          enum: [service-price]
        attributes:
          $ref: '#/components/schemas/ServicePriceAttributes'

    ServicePriceAttributes:
      type: object
      properties:
        service_id:
          type: string
          description: "`standard_article.uID` — die bepreiste Leistung"
        type_id:
          type: string
          nullable: true
          description: "`types.tID`; leer = Pauschale ohne Geraetebezug"
        price_class_id:
          type: string
          nullable: true
          description: >-
            `price_classes.id` — die Preiskategorie (Container der Geraeteachse,
            AP 11). Gesetzt heisst: die Zeile gilt fuer alle Geraetetypen und
            Prozeduren dieser Kategorie. Schliesst `type_id` aus.
        category_id:
          type: string
          nullable: true
          description: "`price_categories.uID`; leer = gilt ohne Preisgruppe"
        complexity:
          type: string
          nullable: true
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        price:
          type: number
        price_min:
          type: number
        price_max:
          type: number
        costs:
          type: number
        quantity:
          type: number
        unit:
          type: string
          nullable: true
        discount:
          type: number
        tax:
          type: number
        service_id_label:
          type: string
          nullable: true
          description: Name der Leistung; `null`, wenn die Referenz fehlt
        type_id_label:
          type: string
          nullable: true
        category_id_label:
          type: string
          nullable: true
        price_class_id_label:
          type: string
          nullable: true
          description: Nummer und Name der Preiskategorie, z. B. `100.110.01 Druck 0-10 bar`

    ServicePriceInput:
      type: object
      description: >-
        `service_id` darf seit AP 11 Teil 2b leer bleiben — dann bepreist die
        Zeile die **Kalibrierung** ueber ihre Preiskategorie. Sie kann nicht in
        `prices` liegen, weil `prices.type_tID` NOT NULL ist und eine
        Kategoriezeile keinen Geraetetyp traegt.

        Ohne `service_id` **und** ohne `price_class_id` antwortet der Endpunkt
        mit 422: das waere ein Preis fuer jeden Geraetetyp ohne Unterscheidung.
        Fuer Zeilen mit `type_id` bleibt `service_id` faktisch Pflicht — die
        Typzeile der Kalibrierung steht in `prices`.

        Geprueft wird der Zustand **nach** dem Schreiben: ein PUT, das nur den
        Betrag aendert, muss die Achsen nicht wiederholen, kann aber auch keinen
        unerlaubten Zustand erzeugen, indem es nur eine davon setzt.
      properties:
        service_id:
          type: string
          maxLength: 80
          nullable: true
          description: Leer = die Zeile bepreist die Kalibrierung (dann ist `price_class_id` Pflicht)
        type_id:
          type: string
          maxLength: 80
          nullable: true
        price_class_id:
          type: string
          maxLength: 80
          nullable: true
        category_id:
          type: string
          maxLength: 80
          nullable: true
        complexity:
          type: string
          maxLength: 255
          nullable: true
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        price:
          type: number
          minimum: 0
        price_min:
          type: number
          minimum: 0
          nullable: true
        price_max:
          type: number
          minimum: 0
          nullable: true
        costs:
          type: number
          minimum: 0
          nullable: true
        quantity:
          type: number
          minimum: 0
        unit:
          type: string
          maxLength: 10
          nullable: true
        discount:
          type: number
          nullable: true
        tax:
          type: number
          nullable: true

    PriceCategoryResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [price-category]
        attributes:
          $ref: '#/components/schemas/PriceCategoryAttributes'

    # ── Standard Article (Katalog) ─────────────────
    StandardArticleAttributes:
      type: object
      properties:
        article_name:
          type: string
          example: "Kalibrierung Standard"
        quantity:
          type: integer
          example: 1
        price:
          type: number
          example: 150
        discount:
          type: integer
          description: "Prozentualer Rabatt als Ganzzahl (0-100) — anders als bei Price/Article, dort Dezimalzahl"
          example: 0
        delivery_costs:
          type: number
          nullable: true
        tax:
          type: integer
          example: 19
        unit:
          type: string
          nullable: true
          example: "EA"
        pos_number:
          type: number
          nullable: true
        calibration_type:
          type: string
          nullable: true
        comment:
          type: string
          nullable: true

    StandardArticleResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [standard-article]
        attributes:
          $ref: '#/components/schemas/StandardArticleAttributes'
