openapi: 3.0.3
info:
  title: ConsignTrak JSON API
  description: |
    JSON API for ConsignTrak — consignment warehouse management system.
    Authenticated via API keys (Bearer token). All responses use JSON.

    This spec documents only the /api/v1/ endpoints. Server-rendered HTML
    endpoints (the screens people use) are not part of the API.
  version: 1.0.0-dev
  contact:
    name: ConsignTrak
servers:
  - url: https://{host}
    description: Your ConsignTrak site
    variables:
      host:
        default: your-site.consigntrak.com
        description: The address you sign in to ConsignTrak at.
security:
  - BearerAuth: []
tags:
  - name: Items
    description: Item master — inventory items with fuzzy search
  - name: Orders
    description: Order entry with atomic creation (header + lines)
  - name: Customers
    description: Customer accounts (not supply-partner-scoped)
  - name: API Keys
    description: API key management (requires admin.api_keys permission)
  - name: Contacts
    description: |
      People associated with supply partners or customers. A single contact
      can be linked to multiple entities, each with an optional role
      string (e.g. `direct_ship`, `billing`). Used by features like the
      direct-ship notify button to look up the right person to email.
  - name: Receiving
    description: |
      Inbound consignment receiving. Batches go through draft → verified →
      posted (or voided), with optional QC inspection on individual lines.
      The bulk-import endpoint accepts a parsed payload directly so n8n /
      Repfabric / scripts don't have to walk the spreadsheet upload flow.
  - name: Cycle Counts
    description: |
      Warehouse-wide cycle count batches. Generate sample, start, record
      counts, approve variances, complete. Refused for manufacturer-scoped
      keys — the random sample crosses supply-partner lines.
  - name: Inventory
    description: Item-level inventory adjustments and movement audit trail.
  - name: Exceptions
    description: |
      Office-side review of pick exceptions (short picks, mispicks) and
      quarantine notes for failed-QC receiving lines.
  - name: Shipping
    description: |
      Per-batch tracking edits and bulk shipment-tracking import. The bulk
      import takes a CSV file and applies it in one shot — ambiguous rows or
      unconfirmed overwrites force a 422 with row-level details.
  - name: Picking
    description: |
      Pick / pack / ship lifecycle for warehouse flows. API keys act as the
      user they were issued to — audit-log identity matches the UI session
      path. The ship endpoint collapses three web routes (mobile ship +
      desktop confirm + ship-with-existing) by treating tracking as optional:
      provide it to capture-then-ship, omit to ship with whatever's already
      on the batch.
  - name: Locations
    description: |
      Warehouse location CRUD, lifecycle (retire/reactivate), and stock
      relocation. Locations are warehouse-wide — no manufacturer scoping.
      Relocate and move-all support an `auto_create` confirm pattern: an
      unknown destination code returns 422 with the code echoed; re-POST with
      the auto-create flag to create-on-scan and proceed.
  - name: Admin Users
    description: |
      User and role administration. Requires the `system_admin` role
      (matches the web's `/admin/users` and `/admin/config` gating).
      Consignor users are created via the invite flow (no password,
      manufacturer required); other roles use direct-create with a
      password. The consignor-create response includes the generated
      `invite_url` — equivalent to the web's invite-delivery page so
      callers on a log-mailer dev setup can deliver the link out-of-band.
  - name: Webhooks
    description: |
      Outbound HTTP webhook subscriptions for the shipment-event surface
      (issue #868). The push counterpart to GET /api/v1/shipments/events:
      each registered subscription receives an HMAC-signed POST for every
      matching event. Tenant-scoped — manufacturer-scoped API keys can
      only manage subscriptions for their own supply partner.

      The plaintext signing_secret is returned ONCE on POST and on
      rotate-secret; subsequent reads omit it. Receivers verify each
      delivery's `X-ConsignTrak-Signature` header (Stripe-style
      `t=<unix>,v1=<hex>` HMAC-SHA256 over `<unix>.<body>`) before
      trusting the payload, and SHOULD reject deliveries whose timestamp
      drifts more than 5 minutes from wall clock to defend against
      replay.

      Delivery semantics: 2xx = delivered; 429 / 5xx / network = retry
      with exponential backoff (1s → 128s, jittered) up to
      max_attempts; other 4xx = permanent failure (no retry — receiver
      rejected the payload). After max_attempts of transient failure the
      delivery is dead-lettered.
  - name: Account Security
    description: |
      Self-service MFA + trusted-device management for the API key
      holder's own account. The endpoints never accept a target user;
      identity is taken from `auth.UserFromContext(...).ID`. Mirrors the
      web `/account/security` page. Step-up: regenerate-recovery and
      disable-MFA require a body `{otp}` when the caller has MFA
      enrolled (the same step-up the web page enforces).
  - name: Reporting
    description: |
      Operational reports for office staff. Read-only, gated by
      `history.view`. Consignor-scoped users get 404 (these are
      warehouse-wide activity views, not tenant-scoped).
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API key with `jwc_` prefix. Generate keys via the admin UI
        or POST /api/v1/api-keys. Include as: `Authorization: Bearer jwc_...`
    FdxSignatureHMAC:
      type: apiKey
      in: header
      name: Fdx-Signature
      description: |
        FedEx AIV webhook signature (issue #1143 Phase 3). The header value
        is `t=<unix-seconds>,v1=<hex-hmac-sha256>`. The HMAC is computed
        over `<unix>.<raw-body-bytes>` using the per-tenant HMAC secret
        configured on `carrier_credentials.hmac_secret`. ConsignTrak
        verifies in constant time and rejects on timestamp drift greater
        than 5 minutes. The URL path's `{tenantToken}` segment selects
        which credential row's secret to verify against; an unknown or
        deleted token returns 410.
  schemas:
    ReceivingImportProfile:
      type: object
      description: A supply partner's saved receiving-import column mapping (#1359).
      properties:
        manufacturer_id:
          type: string
          format: uuid
        sheet_name:
          type: string
          nullable: true
          description: Worksheet the partner's manifests use; null means first sheet / CSV.
        header_row:
          type: integer
          minimum: 1
        column_map:
          type: object
          additionalProperties:
            type: string
          description: >-
            Canonical receiving field (item, qty, ref, cost, uom) → source
            header text.
        updated_at:
          type: string
          format: date-time
    APIError:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              example: unauthorized
            message:
              type: string
              example: API key required
    PaginationMeta:
      type: object
      properties:
        page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
        total_pages:
          type: integer
    Item:
      type: object
      description: |
        Item-master row (internal/models.Item). Population varies by context:
        list rows (`GET /api/v1/items`) select a subset of columns, so fields
        not selected serialize at their zero value (pointers → null, integers
        → 0, booleans → false); detail and mutation responses populate
        everything below except as noted. `manufacturer_code` /
        `manufacturer_name` are join-derived and omitted entirely from
        POST / PATCH item responses. `freight_class_id`, `alternate_item_id`,
        `gl_account_id`, `price_code_id`, `created_by`, `updated_by` are not
        selected by any API query today — always null.
      required:
        - id
        - manufacturer_id
        - item_number_display
        - item_number_normalized
        - description
        - description_extended
        - category
        - freight_class_id
        - alternate_item_id
        - gl_account_id
        - selling_uom
        - purchase_uom
        - conversion_factor
        - unit_weight
        - cubes
        - warehouse_location
        - price_1
        - price_2
        - price_3
        - price_code_id
        - average_cost
        - last_cost
        - qty_on_hand
        - qty_committed
        - qty_available
        - qty_on_order
        - qty_on_backorder
        - ytd_returns
        - reorder_level
        - min_reorder_qty
        - economic_order_qty
        - lead_time_days
        - backorder_control
        - serial_tracking
        - status
        - date_last_ordered
        - date_last_received
        - date_last_movement
        - created_at
        - updated_at
        - created_by
        - updated_by
      properties:
        id:
          type: string
          format: uuid
        manufacturer_id:
          type: string
          format: uuid
        item_number_display:
          type: string
        item_number_normalized:
          type: string
          description: Uppercased matching key with dashes/spaces stripped.
        description:
          type: string
        description_extended:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
        freight_class_id:
          type: string
          format: uuid
          nullable: true
        alternate_item_id:
          type: string
          format: uuid
          nullable: true
        gl_account_id:
          type: string
          format: uuid
          nullable: true
        selling_uom:
          type: string
        purchase_uom:
          type: string
          nullable: true
        conversion_factor:
          type: number
          format: double
          nullable: true
        unit_weight:
          type: number
          format: double
          nullable: true
        cubes:
          type: number
          format: double
          nullable: true
        warehouse_location:
          type: string
          nullable: true
          description: Free-text bin string on the item row (not a locations FK).
        price_1:
          type: number
          format: double
          nullable: true
        price_2:
          type: number
          format: double
          nullable: true
        price_3:
          type: number
          format: double
          nullable: true
        price_code_id:
          type: string
          format: uuid
          nullable: true
        average_cost:
          type: number
          format: double
          nullable: true
        last_cost:
          type: number
          format: double
          nullable: true
        qty_on_hand:
          type: integer
        qty_committed:
          type: integer
        qty_available:
          type: integer
          description: Generated column (on-hand minus committed); can be negative.
        qty_on_order:
          type: integer
        qty_on_backorder:
          type: integer
        ytd_returns:
          type: integer
        reorder_level:
          type: integer
        min_reorder_qty:
          type: integer
        economic_order_qty:
          type: integer
        lead_time_days:
          type: integer
        backorder_control:
          type: boolean
        serial_tracking:
          type: boolean
        status:
          type: string
          enum:
            - active
            - inactive
            - discontinued
        date_last_ordered:
          type: string
          format: date-time
          nullable: true
          description: DATE column; serializes as midnight-UTC date-time.
        date_last_received:
          type: string
          format: date-time
          nullable: true
        date_last_movement:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
        updated_by:
          type: string
          format: uuid
          nullable: true
        manufacturer_code:
          type: string
          description: Join-derived; absent on POST / PATCH item responses.
        manufacturer_name:
          type: string
          description: Join-derived; absent on POST / PATCH item responses.
    ItemSearchResult:
      type: object
      description: |
        One fuzzy-search hit (internal/search.SearchResult). The struct has no
        json tags, so keys are the PascalCase Go field names. The nested Item
        is populated from the same narrow column set as the list endpoint,
        except `created_at` / `updated_at` are not selected and serialize as
        the zero time `0001-01-01T00:00:00Z`.
      required:
        - Item
        - AliasValue
        - Score
        - MatchType
      properties:
        Item:
          $ref: '#/components/schemas/Item'
        AliasValue:
          type: string
          description: The alias value that matched, when the hit came via an item alias.
        Score:
          type: number
          format: double
          description: >-
            1.0 exact; 0.95 prefix / 0.75 substring; pg_trgm similarity for
            fuzzy.
        MatchType:
          type: string
          enum:
            - exact
            - prefix
            - fuzzy
    ShipViaCode:
      type: object
      description: |
        A ship-via carrier from the `ship_via_codes` reference table. `mode`
        (#1411) is the LTL / parcel / will-call classifier the warehouse
        pill, order-entry field visibility, and ship-field normalization all
        key on — the code string is an identity, not a behaviour switch.
      required:
        - id
        - code
        - description
        - mode
        - is_active
      properties:
        id:
          type: string
          format: uuid
        code:
          type: string
          example: LTL
        description:
          type: string
          example: LTL Freight
        carrier_name:
          type: string
          nullable: true
          example: null
        mode:
          type: string
          enum:
            - parcel
            - ltl
            - will_call
          description: >
            parcel — keeps `ship_method` (service level), drops
            `ship_via_other`.

            ltl — keeps `ship_via_other` (carrier write-in), drops
            `ship_method`.

            will_call — drops both and forces `freight_terms` to `WILL CALL`.
        is_active:
          type: boolean
    Order:
      type: object
      description: |
        Order header (internal/models.Order). Population varies by context:
        list rows (`GET /api/v1/orders`) select a subset of columns, so
        columns not selected serialize at their zero value — notably
        `priority` is the empty string on every list row (see its enum), and
        nullable columns like `freight_terms` or the ship-to block read null
        in list context even when set. Detail and create responses populate
        everything below. DATE columns (`order_date`, `request_date`,
        `ship_date`, `scheduled_date`, `expiration_date`,
        `cancellation_date`) serialize as midnight-UTC date-times.
        `customer_discount_pct`, `scheduled_date`, `expiration_date`, and
        `cancellation_date` are not selected by any API query today — always
        null.
      required:
        - id
        - manufacturer_id
        - order_number
        - order_type
        - status
        - priority
        - customer_id
        - customer_po_number
        - salesman_id
        - manufacturer_auth
        - agency_customer_number
        - customer_discount_pct
        - terms_id
        - ship_via_id
        - freight_terms
        - order_date
        - request_date
        - ship_date
        - scheduled_date
        - expiration_date
        - cancellation_date
        - ship_to_name
        - ship_to_address_1
        - ship_to_address_2
        - ship_to_city
        - ship_to_state
        - ship_to_zip
        - drop_ship_location_id
        - bill_to_name
        - bill_to_address_1
        - bill_to_address_2
        - bill_to_city
        - bill_to_state
        - bill_to_zip
        - third_party_payer_id
        - shipping_point
        - freight_charge
        - carrier_account
        - ship_method
        - ship_via_other
        - special_instructions
        - line_count
        - total_weight
        - total_extended_price
        - release_number
        - original_release_number
        - released_by
        - released_at
        - shipped_by
        - shipped_at
        - created_at
        - updated_at
        - created_by
        - updated_by
      properties:
        id:
          type: string
          format: uuid
        manufacturer_id:
          type: string
          format: uuid
        order_number:
          type: integer
          format: int64
        order_type:
          type: string
          enum:
            - order
            - invoice
            - credit
        status:
          type: string
          enum:
            - entered
            - selected
            - released
            - partial
            - shipped
            - billed
            - closed
            - cancelled
        priority:
          type: string
          enum:
            - normal
            - high
            - rush
            - ''
          description: |
            Workflow priority. The list endpoint does not select this column,
            so every list row emits the empty string; detail / create
            responses carry the real value.
        customer_id:
          type: string
          format: uuid
        customer_po_number:
          type: string
          nullable: true
        salesman_id:
          type: string
          format: uuid
          nullable: true
        manufacturer_auth:
          type: string
          nullable: true
        agency_customer_number:
          type: string
          nullable: true
        customer_discount_pct:
          type: number
          format: double
          nullable: true
        terms_id:
          type: string
          format: uuid
          nullable: true
        ship_via_id:
          type: string
          format: uuid
          nullable: true
        freight_terms:
          type: string
          nullable: true
        order_date:
          type: string
          format: date-time
        request_date:
          type: string
          format: date-time
          nullable: true
        ship_date:
          type: string
          format: date-time
          nullable: true
        scheduled_date:
          type: string
          format: date-time
          nullable: true
        expiration_date:
          type: string
          format: date-time
          nullable: true
        cancellation_date:
          type: string
          format: date-time
          nullable: true
        ship_to_name:
          type: string
          nullable: true
        ship_to_address_1:
          type: string
          nullable: true
        ship_to_address_2:
          type: string
          nullable: true
        ship_to_city:
          type: string
          nullable: true
        ship_to_state:
          type: string
          nullable: true
        ship_to_zip:
          type: string
          nullable: true
        drop_ship_location_id:
          type: string
          format: uuid
          nullable: true
        bill_to_name:
          type: string
          nullable: true
        bill_to_address_1:
          type: string
          nullable: true
        bill_to_address_2:
          type: string
          nullable: true
        bill_to_city:
          type: string
          nullable: true
        bill_to_state:
          type: string
          nullable: true
        bill_to_zip:
          type: string
          nullable: true
        third_party_payer_id:
          type: string
          format: uuid
          nullable: true
        shipping_point:
          type: string
          nullable: true
        freight_charge:
          type: number
          format: double
          nullable: true
        carrier_account:
          type: string
          nullable: true
        ship_method:
          type: string
          nullable: true
        ship_via_other:
          type: string
          nullable: true
        special_instructions:
          type: string
          nullable: true
        line_count:
          type: integer
        total_weight:
          type: number
          format: double
          nullable: true
        total_extended_price:
          type: number
          format: double
          nullable: true
        release_number:
          type: integer
          format: int64
          nullable: true
          description: Per-supply-partner release sequence; null until released.
        original_release_number:
          type: integer
          format: int64
          nullable: true
          description: Global release sequence; null until released.
        released_by:
          type: string
          format: uuid
          nullable: true
        released_at:
          type: string
          format: date-time
          nullable: true
        shipped_by:
          type: string
          format: uuid
          nullable: true
        shipped_at:
          type: string
          format: date-time
          nullable: true
        picked_up_at:
          type: string
          format: date-time
          description: Will-call pickup fields; key absent unless recorded.
        picked_up_by_user_id:
          type: string
          format: uuid
        picked_up_by_name:
          type: string
        picked_up_notes:
          type: string
        ltl_carrier:
          type: string
          description: LTL tracking fields; key absent unless recorded.
        ltl_pro_number:
          type: string
        ltl_recorded_at:
          type: string
          format: date-time
        ltl_recorded_by:
          type: string
          format: uuid
        ltl_recorded_by_name:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
        updated_by:
          type: string
          format: uuid
          nullable: true
        manufacturer_code:
          type: string
          description: Join-derived; absent when not selected.
        manufacturer_name:
          type: string
        customer_name:
          type: string
        customer_account:
          type: string
        terms_code:
          type: string
        terms_description:
          type: string
        ship_via_code:
          type: string
        ship_via_description:
          type: string
        ship_via_carrier_name:
          type: string
        ship_via_mode:
          type: string
          enum:
            - parcel
            - ltl
            - will_call
          description: |
            Join-derived ship-via mode (#1411) — the LTL / parcel / will-call
            classifier from `ship_via_codes.mode`. Absent when the order has
            no carrier. Note a will-call pickup entered by freight terms alone
            has `freight_terms: "WILL CALL"` and no `ship_via_mode`; treat
            either as a pickup.
        salesman_code:
          type: string
        salesman_name:
          type: string
        display_substatus:
          type: string
          enum:
            - picking
            - picked
            - packed
          description: |
            Derived pick-progress substate (#1067), computed only by the list
            query. Present for any order with an active pick batch; key absent
            otherwise and on detail / create responses.
    OrderLine:
      type: object
      description: |
        Order line (internal/models.OrderLine). Detail responses populate
        everything below except `item_qty_on_hand` (never selected — key
        always absent). Create responses return a partially-populated line:
        `additional_description` is null, `qty_backordered` is 0,
        `price_override` is false, and all `direct_*` / `item_qty_*` keys are
        absent.
      required:
        - id
        - order_id
        - line_number
        - item_id
        - item_number_display
        - item_description
        - additional_description
        - bin_location
        - qty_ordered
        - qty_shipped
        - qty_backordered
        - fulfillment_source
        - qty_warehouse
        - qty_direct
        - unit_price
        - price_override
        - discount_pct
        - extended_price
        - unit_weight
        - unit_of_measure
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        order_id:
          type: string
          format: uuid
        line_number:
          type: integer
        item_id:
          type: string
          format: uuid
        item_number_display:
          type: string
          description: Snapshot at entry time.
        item_description:
          type: string
          description: Snapshot at entry time.
        additional_description:
          type: string
          nullable: true
        bin_location:
          type: string
          nullable: true
        qty_ordered:
          type: integer
        qty_shipped:
          type: integer
        qty_backordered:
          type: integer
        fulfillment_source:
          type: string
          enum:
            - warehouse
            - direct
            - split
        qty_warehouse:
          type: integer
        qty_direct:
          type: integer
        unit_price:
          type: number
          format: double
        price_override:
          type: boolean
        discount_pct:
          type: number
          format: double
          description: >-
            Decimal fraction (0.10 = 10%), not a percent. extended_price =
            qty_ordered * unit_price * (1 - discount_pct).
        extended_price:
          type: number
          format: double
        unit_weight:
          type: number
          format: double
          nullable: true
        unit_of_measure:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        direct_shipped_at:
          type: string
          format: date-time
          description: Direct-fulfillment shipment fields; keys absent unless set.
        direct_shipped_by:
          type: string
          format: uuid
        direct_tracking_number:
          type: string
        direct_carrier:
          type: string
          description: Normalized to ups/fedex/usps/other since
        direct_ship_note:
          type: string
        direct_shipped_source:
          type: string
          enum:
            - office
            - consignor
            - api
        direct_shipped_api_key_id:
          type: string
          format: uuid
        direct_notified_at:
          type: string
          format: date-time
        direct_notified_by:
          type: string
          format: uuid
        direct_notified_via:
          type: string
          enum:
            - email
            - out_of_band
        item_qty_on_hand:
          type: integer
          description: Never selected by any API query today — key always absent.
        item_qty_available:
          type: integer
          description: Current availability of the line's item (detail responses only).
    Location:
      type: object
      description: |
        Warehouse location (internal/models.WarehouseLocation). Locations are
        warehouse-wide — no supply-partner scoping. `warehouse_name` is
        join-derived: present on PATCH / retire / reactivate / move-all `dest`
        responses, absent on the POST create response. `item_count` /
        `total_qty` are never populated by API responses today (list-view
        fields) — keys always absent.
      required:
        - id
        - warehouse_id
        - zone
        - aisle
        - rack
        - shelf
        - bin
        - location_code
        - location_type
        - capacity
        - is_active
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        warehouse_id:
          type: string
          format: uuid
        zone:
          type: string
          nullable: true
        aisle:
          type: string
          nullable: true
        rack:
          type: string
          nullable: true
        shelf:
          type: string
          nullable: true
        bin:
          type: string
          nullable: true
        location_code:
          type: string
        location_type:
          type: string
          enum:
            - storage
            - staging
            - receiving
            - shipping
            - quarantine
        capacity:
          type: integer
          nullable: true
          description: Sending 0 stores (and reads back) null.
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        warehouse_name:
          type: string
        item_count:
          type: integer
        total_qty:
          type: integer
    ReceivingBatch:
      type: object
      description: |
        Receiving batch (internal/models.ReceivingBatch). The create and
        import responses return the freshly-inserted row: `created_by` /
        `updated_by` null and `manufacturer_code` / `manufacturer_name` keys
        absent (no join). Every other endpoint reloads the batch and includes
        all four. `line_count` / `total_qty` (and `manufacturer_code` /
        `manufacturer_name`) are populated by the `GET /api/v1/receiving`
        list (#1396); other responses omit the count keys. `received_date`
        is a DATE column; serializes as a midnight-UTC date-time.
      required:
        - id
        - manufacturer_id
        - batch_number
        - status
        - received_date
        - posted_date
        - posted_by
        - notes
        - created_at
        - updated_at
        - created_by
        - updated_by
      properties:
        id:
          type: string
          format: uuid
        manufacturer_id:
          type: string
          format: uuid
        batch_number:
          type: string
          description: Minted as RB-YYYYMMDD-XXXXXX.
        status:
          type: string
          enum:
            - draft
            - verified
            - posted
            - voided
          description: >-
            The DB enum also contains a legacy `rejected` member the application
            never writes.
        received_date:
          type: string
          format: date-time
        posted_date:
          type: string
          format: date-time
          nullable: true
        posted_by:
          type: string
          format: uuid
          nullable: true
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
        updated_by:
          type: string
          format: uuid
          nullable: true
        manufacturer_code:
          type: string
        manufacturer_name:
          type: string
        line_count:
          type: integer
        total_qty:
          type: integer
    ReceivingLine:
      type: object
      description: |
        Receiving batch line (internal/models.ReceivingLine). Batch-reload
        responses populate everything below; the add-line 201 response is
        partial — the QC fields (`qty_passed`, `qty_failed`,
        `inspection_notes`, `inspected_by`, `inspected_at`) are null and the
        joined keys (`item_qty_on_hand`, `location_code`,
        `inspected_by_name`) absent. `stock_order_line_id`, `new_price`,
        `old_price`, `created_by`, `updated_by` are never selected by any API
        query today — always null.
      required:
        - id
        - batch_id
        - item_id
        - stock_order_line_id
        - reference_number
        - reference_type
        - qty_received
        - qty_expected
        - new_unit_cost
        - old_unit_cost
        - new_price
        - old_price
        - new_location_id
        - old_location
        - inspection_status
        - qty_passed
        - qty_failed
        - inspection_notes
        - inspected_by
        - inspected_at
        - item_number_display
        - item_description
        - created_at
        - updated_at
        - created_by
        - updated_by
      properties:
        id:
          type: string
          format: uuid
        batch_id:
          type: string
          format: uuid
        item_id:
          type: string
          format: uuid
        stock_order_line_id:
          type: string
          format: uuid
          nullable: true
        reference_number:
          type: string
          nullable: true
        reference_type:
          type: string
          nullable: true
          enum:
            - purchase_order
            - stock_transfer
            - stock_adjustment
            - cycle_count
            - return
            - other
            - null
        qty_received:
          type: integer
        qty_expected:
          type: integer
          nullable: true
          description: Import-time expected quantity; null for manually-added lines.
        new_unit_cost:
          type: number
          format: double
          nullable: true
        old_unit_cost:
          type: number
          format: double
          nullable: true
          description: Snapshot of the item's average cost at line entry.
        new_price:
          type: number
          format: double
          nullable: true
        old_price:
          type: number
          format: double
          nullable: true
        new_location_id:
          type: string
          format: uuid
          nullable: true
        old_location:
          type: string
          nullable: true
          description: Snapshot of the item's free-text warehouse_location (not a uuid).
        inspection_status:
          type: string
          enum:
            - pending
            - passed
            - failed
            - partial
            - waived
        qty_passed:
          type: integer
          nullable: true
        qty_failed:
          type: integer
          nullable: true
        inspection_notes:
          type: string
          nullable: true
        inspected_by:
          type: string
          format: uuid
          nullable: true
        inspected_at:
          type: string
          format: date-time
          nullable: true
        item_number_display:
          type: string
          description: Snapshot at line entry.
        item_description:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
        updated_by:
          type: string
          format: uuid
          nullable: true
        item_qty_on_hand:
          type: integer
          nullable: true
        location_code:
          type: string
        inspected_by_name:
          type: string
    PickSession:
      type: object
      description: |
        Pick batch lifecycle state (internal/orders.PickSession — the wire
        entity for pick batches; `ID` is the pick_batches row id). The struct
        has NO json tags, so keys are the PascalCase Go field names — clients
        keyed on snake_case will silently read empty objects. On the 201 from
        starting a batch, the tracking and staging fields are all null and
        each line's `PickBatchLineID` is the empty string; subsequent
        lifecycle responses reload fully.
      required:
        - ID
        - OrderID
        - OrderNumber
        - CustomerName
        - ManufacturerName
        - Status
        - Lines
        - TrackingNumbers
        - TrackingNumber
        - TrackingCarrier
        - TrackingCapturedAt
        - TrackingFlaggedForOfficeAt
        - Parcels
        - StagedSlot
        - StagedAt
      properties:
        ID:
          type: string
          format: uuid
        OrderID:
          type: string
          format: uuid
        OrderNumber:
          type: integer
          format: int64
        CustomerName:
          type: string
        ManufacturerName:
          type: string
        Status:
          type: string
          enum:
            - in_progress
            - completed
            - packed
            - shipped
          description: >-
            The DB enum also contains draft / released / cancelled, which this
            flow never writes.
        Lines:
          type: array
          nullable: true
          items:
            $ref: '#/components/schemas/PickSessionLine'
          description: null, not [], when empty.
        TrackingNumbers:
          type: array
          nullable: true
          items:
            type: string
          deprecated: true
          description: Dead field — never populated; always null.
        TrackingNumber:
          type: string
          nullable: true
          description: Primary box (lowest live parcel_seq) tracking number.
        TrackingCarrier:
          type: string
          nullable: true
          enum:
            - ups
            - fedex
            - usps
            - other
            - null
        TrackingCapturedAt:
          type: string
          format: date-time
          nullable: true
        TrackingFlaggedForOfficeAt:
          type: string
          format: date-time
          nullable: true
        Parcels:
          type: array
          nullable: true
          items:
            $ref: '#/components/schemas/PickSessionParcel'
          description: Every live box on the batch. null, not [], when empty.
        StagedSlot:
          type: string
          nullable: true
          description: >-
            Staging area / lane location_code (#1377). null with StagedAt set =
            direct handoff.
        StagedAt:
          type: string
          format: date-time
          nullable: true
        StagedLocType:
          type: string
          nullable: true
          enum:
            - staging
            - shipping
            - null
          description: >-
            #1413: whether StagedSlot is a pick-side staging area or a shipping
            lane the packed cartons were moved to. null when not staged.
    PickSessionLine:
      type: object
      description: |
        One line of a pick session (internal/orders.PickSessionLine). No json
        tags — PascalCase keys. All keys always present; nullable DB columns
        are flattened to empty strings.
      required:
        - PickBatchLineID
        - OrderLineID
        - ItemID
        - ItemNumberDisplay
        - ItemDescription
        - BinLocation
        - UnitOfMeasure
        - QtyToPick
        - QtyPicked
        - QtyShort
        - ShortReason
        - Status
        - QtyOnHand
        - QtyAvailable
        - ExceptionStatus
      properties:
        PickBatchLineID:
          type: string
          description: >-
            pick_batch_lines id (for undo-short URLs). Empty string on the
            start-batch 201.
        OrderLineID:
          type: string
          format: uuid
        ItemID:
          type: string
          format: uuid
        ItemNumberDisplay:
          type: string
        ItemDescription:
          type: string
        BinLocation:
          type: string
        UnitOfMeasure:
          type: string
        QtyToPick:
          type: integer
        QtyPicked:
          type: integer
        QtyShort:
          type: integer
        ShortReason:
          type: string
        Status:
          type: string
          enum:
            - pending
            - picked
            - short
            - skipped
        QtyOnHand:
          type: integer
        QtyAvailable:
          type: integer
        ExceptionStatus:
          type: string
          enum:
            - ''
            - open
            - reviewed
            - superseded
          description: >-
            Short-pick exception state (#678); empty string when no exception
            row.
        PackVerifiedAt:
          type: string
          format: date-time
          nullable: true
          description: >-
            #1413: when the packer confirmed this line against the goods
            (stamped by the pack call). null until packed, and forever on
            batches packed before the column existed.
        LocZone:
          type: string
          description: >-
            #1413: zone of the warehouse_locations row whose location_code
            matches BinLocation; empty when no row matches.
        LocAisle:
          type: string
          description: As LocZone, for aisle.
        LocRack:
          type: string
          description: As LocZone, for rack.
        LocShelf:
          type: string
          description: As LocZone, for shelf.
    PickSessionParcel:
      type: object
      description: |
        One live box on a pick batch (internal/orders.Parcel). No json tags —
        PascalCase keys. Distinct from the snake_case parcel shape returned
        by the /parcels endpoints.
      required:
        - ID
        - PickBatchID
        - ParcelSeq
        - TrackingNumber
        - TrackingCarrier
        - TrackingCapturedAt
        - TrackingCapturedByName
      properties:
        ID:
          type: string
          format: uuid
        PickBatchID:
          type: string
          format: uuid
        ParcelSeq:
          type: integer
        TrackingNumber:
          type: string
        TrackingCarrier:
          type: string
          enum:
            - ups
            - fedex
            - usps
            - other
        TrackingCapturedAt:
          type: string
          format: date-time
        TrackingCapturedByName:
          type: string
          nullable: true
    AdjustmentMovement:
      type: object
      description: One row of the adjustments review ledger (#1334).
      properties:
        id:
          type: string
          format: uuid
        item_id:
          type: string
          format: uuid
        item_number_display:
          type: string
        manufacturer_id:
          type: string
          format: uuid
        manufacturer_name:
          type: string
        movement_type:
          type: string
          enum:
            - adjustment
            - cycle_count_correction
        quantity:
          type: integer
          description: Signed delta (positive added stock, negative removed).
        balance_after:
          type: integer
        reference_type:
          type: string
        reference_id:
          type: string
          format: uuid
        cycle_count_batch_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            Parent cycle-count batch for cycle_count_correction rows; null for
            manual adjustments.
        reason:
          type: string
        performed_by_name:
          type: string
        performed_at:
          type: string
          format: date-time
    InventoryMovement:
      type: object
      description: |
        One row of the full inventory-movement ledger (#1396). Same shape as
        AdjustmentMovement with movement_type widened to every enum value.
      properties:
        id:
          type: string
          format: uuid
        item_id:
          type: string
          format: uuid
        item_number_display:
          type: string
        manufacturer_id:
          type: string
          format: uuid
        manufacturer_name:
          type: string
        movement_type:
          type: string
          enum:
            - receipt
            - shipment
            - adjustment
            - transfer
            - return
            - cycle_count_correction
        quantity:
          type: integer
          description: Signed delta (positive added stock, negative removed).
        balance_after:
          type: integer
        reference_type:
          type: string
        reference_id:
          type: string
          format: uuid
        cycle_count_batch_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            Parent cycle-count batch for cycle_count_correction rows; null
            otherwise.
        reason:
          type: string
        performed_by_name:
          type: string
        performed_at:
          type: string
          format: date-time
    WarehouseLocation:
      type: object
      description: >-
        A warehouse bin/location. Warehouse-wide shared infrastructure — not
        supply-partner-scoped.
      properties:
        id:
          type: string
          format: uuid
        warehouse_id:
          type: string
          format: uuid
        zone:
          type: string
          nullable: true
        aisle:
          type: string
          nullable: true
        rack:
          type: string
          nullable: true
        shelf:
          type: string
          nullable: true
        bin:
          type: string
          nullable: true
        location_code:
          type: string
        location_type:
          type: string
        capacity:
          type: integer
          nullable: true
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        warehouse_name:
          type: string
          nullable: true
        item_count:
          type: integer
          description: Distinct items stored here.
        total_qty:
          type: integer
          description: Total units stored here.
    LocationStockItem:
      type: object
      description: One item's stock sitting in a bin (GET /locations/{id}/stock).
      properties:
        item_id:
          type: string
          format: uuid
        item_number_display:
          type: string
        description:
          type: string
        manufacturer_name:
          type: string
          nullable: true
        qty_on_hand:
          type: integer
        inventory_status:
          type: string
        is_primary:
          type: boolean
    ItemLocation:
      type: object
      description: One bin holding stock of an item (GET /items/{id}/locations).
      properties:
        id:
          type: string
          format: uuid
        item_id:
          type: string
          format: uuid
        location_id:
          type: string
          format: uuid
        qty_on_hand:
          type: integer
        inventory_status:
          type: string
        is_primary:
          type: boolean
        location_code:
          type: string
          nullable: true
    CycleCountBatch:
      type: object
      description: A cycle-count batch header.
      properties:
        id:
          type: string
          format: uuid
        batch_number:
          type: string
        status:
          type: string
          enum:
            - draft
            - in_progress
            - completed
            - cancelled
        triggered_by:
          type: string
        assigned_to:
          type: string
          format: uuid
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        line_count:
          type: integer
        counted_count:
          type: integer
    CycleCountLine:
      type: object
      description: One line on a cycle-count batch, with the recorded count and variance.
      properties:
        id:
          type: string
          format: uuid
        batch_id:
          type: string
          format: uuid
        item_id:
          type: string
          format: uuid
        location_id:
          type: string
          format: uuid
        system_qty:
          type: integer
        counted_qty:
          type: integer
          nullable: true
        variance:
          type: integer
          nullable: true
        status:
          type: string
        counted_by:
          type: string
          format: uuid
          nullable: true
        counted_at:
          type: string
          format: date-time
          nullable: true
        item_number_display:
          type: string
          nullable: true
        item_description:
          type: string
          nullable: true
        location_code:
          type: string
          nullable: true
    PickBatchParcel:
      type: object
      description: One live box on a pick batch.
      properties:
        id:
          type: string
          format: uuid
        parcel_seq:
          type: integer
        tracking_number:
          type: string
        carrier:
          type: string
        captured_at:
          type: string
          format: date-time
    OrderPickBatch:
      type: object
      description: >-
        One pick batch on an order, with tracking state (GET
        /orders/{orderID}/pick-batches).
      properties:
        batch_id:
          type: string
          format: uuid
        batch_number:
          type: string
        status:
          type: string
        packed_at:
          type: string
          format: date-time
          nullable: true
        packed_by_name:
          type: string
          nullable: true
        tracking_number:
          type: string
          nullable: true
          description: Primary box (lowest live parcel_seq).
        tracking_carrier:
          type: string
          nullable: true
        tracking_captured_at:
          type: string
          format: date-time
          nullable: true
        staged_slot:
          type: string
          nullable: true
        staged_at:
          type: string
          format: date-time
          nullable: true
        parcels:
          type: array
          items:
            $ref: '#/components/schemas/PickBatchParcel'
    Document:
      type: object
      description: >-
        A stored attachment (models.Document). Bytes via GET
        /api/v1/documents/{id}/download.
      properties:
        id:
          type: string
          format: uuid
        entity_type:
          type: string
          description: >-
            `order`, `pick_batch`, `short_pick_exception`, `mispick_exception`,
            `quality_exception`, ...
        entity_id:
          type: string
          format: uuid
        manufacturer_id:
          type: string
          format: uuid
          nullable: true
        file_name:
          type: string
        file_type:
          type: string
        file_size:
          type: integer
        storage_key:
          type: string
        description:
          type: string
          nullable: true
        uploaded_by:
          type: string
          format: uuid
        uploaded_at:
          type: string
          format: date-time
        tags:
          type: array
          items:
            type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        uploaded_by_name:
          type: string
          nullable: true
    OrderLifecycle:
      type: object
      description: Lifecycle timeline for one order (GET /orders/{id}?include=fulfillment,
      properties:
        entered_at:
          type: string
          format: date-time
        entered_by_name:
          type: string
          nullable: true
        released_at:
          type: string
          format: date-time
          nullable: true
        released_by_name:
          type: string
          nullable: true
        batch_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            Most recent non-cancelled pick batch; feed to GET
            /pick-batches/{batchID}.
        batch_status:
          type: string
          nullable: true
        pick_started_at:
          type: string
          format: date-time
          nullable: true
        pick_started_by_name:
          type: string
          nullable: true
        packed_at:
          type: string
          format: date-time
          nullable: true
        packed_by_name:
          type: string
          nullable: true
        shipped_at:
          type: string
          format: date-time
          nullable: true
        shipped_by_name:
          type: string
          nullable: true
        picked_up_at:
          type: string
          format: date-time
          nullable: true
        picked_up_by_name:
          type: string
          nullable: true
        cancelled_at:
          type: string
          format: date-time
          nullable: true
        cancelled_by_name:
          type: string
          nullable: true
        cancelled_reason:
          type: string
          nullable: true
    PendingDirectLine:
      type: object
      description: >-
        An order line with a direct-ship quantity the supply partner has not
        shipped yet.
      properties:
        order_line_id:
          type: string
          format: uuid
        line_number:
          type: integer
        item_number_display:
          type: string
        qty_direct:
          type: integer
        direct_notified_at:
          type: string
          format: date-time
          nullable: true
        direct_notified_via:
          type: string
          nullable: true
          description: '`email` or `out_of_band`.'
    OrderFulfillment:
      type: object
      description: Present only with `include=fulfillment` on GET /orders/{id} (#1416).
      properties:
        display_substatus:
          type: string
          description: '`picking`, `picked`, `packed`, or `""` when no active batch.'
        has_warehouse_portion:
          type: boolean
        has_direct_portion:
          type: boolean
        pending_direct_lines:
          type: array
          items:
            $ref: '#/components/schemas/PendingDirectLine'
        direct_notify_last_at:
          type: string
          format: date-time
          nullable: true
        lifecycle:
          $ref: '#/components/schemas/OrderLifecycle'
    ExceptionSummary:
      type: object
      description: >-
        Triage-sized view of the pick exception behind an order block. Full row
        at GET /exceptions/{id}, /exceptions/mispicks/{id},
        /exceptions/quality/{id}.
      properties:
        id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - short_pick
            - mispick
            - quality
        status:
          type: string
          enum:
            - open
            - reviewed
            - superseded
        batch_number:
          type: string
        item_number_display:
          type: string
        item_description:
          type: string
        qty_short:
          type: integer
          description: Short-pick only; 0 otherwise.
        short_reason:
          type: string
          description: Short-pick only; omitted when empty.
        flag_reason:
          type: string
          description: Mispick / quality only; omitted when empty.
        actual_item_number_display:
          type: string
          nullable: true
        actual_qty:
          type: integer
          nullable: true
    OrderBlock:
      type: object
      description: >-
        An order_blocks row (GET /orders/{id}?include=blocks and the `block`
        field on exception detail,
      properties:
        id:
          type: string
          format: uuid
        order_id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - short_pick
            - mispick
            - quality
        status:
          type: string
          enum:
            - open
            - resolved
            - cancelled
        short_pick_exception_id:
          type: string
          format: uuid
          nullable: true
        mispick_exception_id:
          type: string
          format: uuid
          nullable: true
        quality_exception_id:
          type: string
          format: uuid
          nullable: true
        awaiting_supply_partner_at:
          type: string
          format: date-time
          nullable: true
        awaiting_supply_partner_note:
          type: string
          nullable: true
        resolved_at:
          type: string
          format: date-time
          nullable: true
        resolution_disposition:
          type: string
          nullable: true
        resolution_note:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        exception:
          allOf:
            - $ref: '#/components/schemas/ExceptionSummary'
          nullable: true
          description: >-
            Populated on the order-detail include; null on exception detail (the
            full row is alongside) or when the exception row is gone.
    OrderDocuments:
      type: object
      description: >-
        Present only with `include=documents` on GET /orders/{id} (#1416).
        Download via GET /documents/{id}/download.
      properties:
        documents:
          type: array
          description: >-
            Every attachment on the order, packing list included. `[]` when
            none.
          items:
            $ref: '#/components/schemas/Document'
        packing_list:
          allOf:
            - $ref: '#/components/schemas/Document'
          nullable: true
          description: The current supply-partner packing list, or null.
    PickException:
      type: object
      description: >
        One pick exception of any kind (GET /exceptions, #1416). One shape for
        all three kinds:

        short-pick fields are zero for mispick/quality rows and vice versa.

        `awaiting_supply_partner` is derived from the exception's open order
        block.
      properties:
        kind:
          type: string
          enum:
            - short_pick
            - mispick
            - quality
        id:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - open
            - reviewed
            - superseded
        pick_batch_line_id:
          type: string
          description: Empty for batch-level quality flags.
        batch_id:
          type: string
          format: uuid
        batch_number:
          type: string
        order_id:
          type: string
          description: Empty for batch-level quality flags (no line to resolve through).
        order_number:
          type: integer
        customer_name:
          type: string
        manufacturer_id:
          type: string
          format: uuid
        manufacturer_name:
          type: string
        item_id:
          type: string
        item_number_display:
          type: string
        item_description:
          type: string
        bin_location:
          type: string
        picker_id:
          type: string
        picker_display_name:
          type: string
        created_at:
          type: string
          format: date-time
        qty_to_pick:
          type: integer
        qty_picked_snapshot:
          type: integer
          description: qty_picked at the moment the short was recorded.
        qty_short:
          type: integer
        short_reason:
          type: string
        flag_reason:
          type: string
        flag_note:
          type: string
          nullable: true
        actual_item_id:
          type: string
          format: uuid
          nullable: true
        actual_item_number_display:
          type: string
          nullable: true
        actual_qty:
          type: integer
          nullable: true
        review_disposition:
          type: string
          nullable: true
        batch_level:
          type: boolean
          description: Quality flag raised on the whole batch rather than one line.
        reviewed_by_id:
          type: string
          format: uuid
          nullable: true
        reviewed_by_display_name:
          type: string
          nullable: true
        reviewed_at:
          type: string
          format: date-time
          nullable: true
        review_note:
          type: string
          nullable: true
        superseded_by_id:
          type: string
          format: uuid
          nullable: true
        superseded_by_display_name:
          type: string
          nullable: true
        superseded_at:
          type: string
          format: date-time
          nullable: true
        superseded_reason:
          type: string
          nullable: true
        awaiting_supply_partner:
          type: boolean
    ExceptionDetail:
      type: object
      description: >-
        Exception detail envelope (GET /exceptions/{id},
        /exceptions/mispicks/{id}, /exceptions/quality/{id},
      properties:
        exception:
          $ref: '#/components/schemas/PickException'
        block:
          allOf:
            - $ref: '#/components/schemas/OrderBlock'
          nullable: true
          description: >-
            The most recent order block for this exception, or null
            (non-blocking quality reasons never open one).
        documents:
          type: array
          description: Photos attached to the exception. `[]` when none.
          items:
            $ref: '#/components/schemas/Document'
    PickBatchLine:
      type: object
      description: One line of a pick batch (pick-session shape).
      properties:
        pick_batch_line_id:
          type: string
          format: uuid
        order_line_id:
          type: string
          format: uuid
        item_id:
          type: string
          format: uuid
        item_number_display:
          type: string
        item_description:
          type: string
        bin_location:
          type: string
        unit_of_measure:
          type: string
        qty_to_pick:
          type: integer
        qty_picked:
          type: integer
        qty_short:
          type: integer
        short_reason:
          type: string
        status:
          type: string
          enum:
            - pending
            - picked
            - short
            - skipped
    PickBatchDetail:
      type: object
      description: Pick-batch detail (GET /pick-batches/{batchID}).
      properties:
        id:
          type: string
          format: uuid
        order_id:
          type: string
          format: uuid
        order_number:
          type: integer
        customer_name:
          type: string
        manufacturer_name:
          type: string
        status:
          type: string
        lines:
          type: array
          items:
            $ref: '#/components/schemas/PickBatchLine'
        tracking_number:
          type: string
          nullable: true
        tracking_carrier:
          type: string
          nullable: true
        tracking_captured_at:
          type: string
          format: date-time
          nullable: true
        parcels:
          type: array
          items:
            $ref: '#/components/schemas/PickBatchParcel'
        staged_slot:
          type: string
          nullable: true
        staged_at:
          type: string
          format: date-time
          nullable: true
    AdminUserListItem:
      allOf:
        - $ref: '#/components/schemas/AdminUser'
        - type: object
          properties:
            mfa_locked:
              type: boolean
            pin_set:
              type: boolean
            last_login_at:
              type: string
              format: date-time
              nullable: true
    CreateOrderRequest:
      type: object
      required:
        - manufacturer_id
        - customer_id
        - lines
      properties:
        manufacturer_id:
          type: string
          format: uuid
        customer_id:
          type: string
          format: uuid
        order_date:
          type: string
          format: date
          description: YYYY-MM-DD (defaults to today)
        customer_po_number:
          type: string
        manufacturer_auth:
          type: string
        freight_terms:
          type: string
          description: >
            Freight terms (e.g. COLLECT, PREPAID, 3RD PARTY, WILL CALL).
            Selecting

            the Will Call carrier in `ship_via_id` forces this to `WILL CALL`

            regardless of the value sent — the customer-pickup workflow keys off

            freight_terms (#1369).
        ship_via_id:
          type: string
          format: uuid
          nullable: true
          description: >
            Carrier — a ship_via_codes UUID (UPS / FedEx / LTL / Will Call
            seeded

            in every environment). Anchors the ship-via triple below. The Will
            Call

            carrier is the customer-pickup signal: setting it forces

            `freight_terms = "WILL CALL"` (#1369).
        ship_method:
          type: string
          nullable: true
          description: |
            Parcel service level (e.g. "Ground", "Next Day Air"). Normalized
            against the carrier on write: kept for parcel carriers (UPS/FedEx),
            cleared for LTL / Will Call.
        ship_via_other:
          type: string
          nullable: true
          description: |
            Free-text LTL carrier write-in (e.g. "Old Dominion"). Kept only when
            the carrier is LTL; cleared otherwise.
        carrier_account:
          type: string
        shipping_point:
          type: string
        special_instructions:
          type: string
        priority:
          type: string
          enum:
            - normal
            - high
            - rush
          default: normal
          description: >
            Workflow priority. High/rush values sort the pick queue ahead of
            normal,

            surface the order in the dashboard attention list, and render a pill
            on

            pick/pack queues. Applied via SetOrderPriority after create — the
            same

            audit event fires as for the HTML form path.
        drop_ship_location_id:
          type: string
          format: uuid
          nullable: true
          description: |
            When set, the order ships from the warehouse to a third-party
            address (the customer's customer, a job site, etc.). The location
            must belong to `customer_id` and be active. Its address is
            snapshotted into the order's `ship_to_*` fields at create time;
            later edits to the location row do not affect this order.
            Distinct from per-line `fulfillment_source = direct`, which is a
            supply-partner-ships-from-their-warehouse swap.
        third_party_payer_id:
          type: string
          format: uuid
          nullable: true
          description: |
            Applied only when `freight_terms` = "3RD PARTY". When set, the
            payer's address + carrier account are snapshotted into the order's
            `bill_to_*` and `carrier_account`. The payer must belong to
            `customer_id` and be active, else the create is rejected.
        bill_to_name:
          type: string
          description: >-
            Inline 3rd-party bill-to (used when `third_party_payer_id` is absent
            and freight_terms = "3RD PARTY").
        bill_to_address_1:
          type: string
        bill_to_address_2:
          type: string
        bill_to_city:
          type: string
        bill_to_state:
          type: string
        bill_to_zip:
          type: string
        lines:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/CreateOrderLineRequest'
    CreateOrderLineRequest:
      type: object
      required:
        - item_id
        - qty_ordered
        - unit_price
      properties:
        item_id:
          type: string
          format: uuid
        qty_ordered:
          type: integer
          minimum: 1
          maximum: 100000000
        unit_price:
          type: number
          format: double
          minimum: 0
          maximum: 99999999
        discount_pct:
          type: number
          format: double
          default: 0
        fulfillment_source:
          type: string
          enum:
            - warehouse
            - direct
            - split
          default: warehouse
        qty_warehouse:
          type: integer
          description: Required for split fulfillment
        qty_direct:
          type: integer
          description: Required for split fulfillment
        acknowledge_negative:
          type: boolean
          default: false
          description: Acknowledge that this line will drive qty_available negative
        acknowledge_exception:
          type: boolean
          default: false
          description: >-
            Override the open-pick-exception gate for this line (422
            `exception_blocked` without it when the item has an open exception)
    CreateAPIKeyRequest:
      type: object
      required:
        - user_id
        - name
        - scopes
      properties:
        user_id:
          type: string
          format: uuid
        name:
          type: string
        scopes:
          type: array
          items:
            type: string
          description: Permission codes (e.g. orders.view, inventory.edit)
        expires_at:
          type: string
          description: RFC3339 or YYYY-MM-DD (optional)
    CreateCustomerRequest:
      type: object
      required:
        - account_number
        - name
      properties:
        account_number:
          type: string
          description: Unique account identifier; service rejects duplicates with 422
        name:
          type: string
        customer_type_code:
          type: string
        region:
          type: string
        address_line_1:
          type: string
        address_line_2:
          type: string
        city:
          type: string
        state:
          type: string
        zip_code:
          type: string
    UpdateCustomerRequest:
      type: object
      required:
        - name
      description: |
        PATCH body for /api/v1/customers/{id}. Empty strings clear NULLable
        fields (matches the web form's NULLIF behavior). account_number and
        status are not editable through this endpoint.
      properties:
        name:
          type: string
        customer_type_code:
          type: string
        region:
          type: string
        address_line_1:
          type: string
        address_line_2:
          type: string
        city:
          type: string
        state:
          type: string
        zip_code:
          type: string
    CreateManufacturerRequest:
      type: object
      required:
        - code
        - name
      description: |
        POST body for /api/v1/manufacturers. commission_rate is a decimal
        fraction (0.10 = 10%); the web form takes a percentage but the API
        takes the storage form.
      properties:
        code:
          type: string
          description: Legacy CAS manufacturer code (e.g. "5" for AMEC). Unique.
        name:
          type: string
        short_name:
          type: string
        type_code:
          type: string
          default: consigned
        rep_code:
          type: string
        commission_rate:
          type: number
          format: float
          nullable: true
          description: Decimal fraction (0.10 = 10%)
        selling_agency:
          type: string
        shipping_agency:
          type: string
        freight_billing_info:
          type: string
        notes:
          type: string
    UpdateManufacturerRequest:
      type: object
      required:
        - name
      description: |
        PATCH body for /api/v1/manufacturers/{id}. Omitting type_code or
        statement_cadence preserves the existing value, matching the web
        "mobile form omits the field" fallback.
      properties:
        name:
          type: string
        short_name:
          type: string
        type_code:
          type: string
        rep_code:
          type: string
        commission_rate:
          type: number
          format: float
          nullable: true
          description: Decimal fraction (0.10 = 10%)
        selling_agency:
          type: string
        shipping_agency:
          type: string
        freight_billing_info:
          type: string
        notes:
          type: string
        statement_cadence:
          type: string
          enum:
            - monthly
            - quarterly
          description: When omitted, the existing cadence is preserved.
    SetPriorityRequest:
      type: object
      required:
        - priority
      properties:
        priority:
          type: string
          enum:
            - normal
            - high
            - rush
    RecordLTLTrackingRequest:
      type: object
      description: |
        LTL (less-than-truckload / freight) post-ship tracking. Both fields are
        optional and recorded independently. Submitting both blank clears any
        prior record.
      properties:
        carrier:
          type: string
          description: Free-text LTL freight carrier name (e.g. "Old Dominion").
          maxLength: 120
        pro_number:
          type: string
          description: Freight PRO# / tracking number.
          maxLength: 120
    ItemPrice:
      type: object
      description: >
        A supply-partner-set unit price for a specific item, scoped by an

        effective window. The "active" row at time T is the one whose

        `effective_from <= T` and (`effective_to IS NULL` OR `effective_to >
        T`).

        ConsignTrak displays this read-only on pick tickets, order detail,

        and the mobile picker; ConsignTrak does not invoice.
      properties:
        id:
          type: string
          format: uuid
        manufacturer_id:
          type: string
          format: uuid
        item_id:
          type: string
          format: uuid
        unit_price:
          type: number
          format: float
          description: USD; non-negative.
        effective_from:
          type: string
          format: date-time
        effective_to:
          type: string
          format: date-time
          nullable: true
          description: Null when this is the open row.
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
        updated_by:
          type: string
          format: uuid
          nullable: true
    UpsertItemPriceRequest:
      type: object
      required:
        - unit_price
      description: |
        PUT body for /api/v1/manufacturers/{id}/items/{item_id}/price.
        `effective_from` is optional (defaults to server now). When supplied,
        it must be strictly later than the prior open row's `effective_from`
        — otherwise 422 effective_from_too_early.
      properties:
        unit_price:
          type: number
          format: float
          description: USD; must be >= 0.
        effective_from:
          type: string
          format: date-time
          nullable: true
          description: RFC3339 timestamp; defaults to server now when omitted.
        notes:
          type: string
          nullable: true
    UpdateItemPriceNotesRequest:
      type: object
      description: |
        PATCH body for /api/v1/manufacturers/{id}/item-prices/{price_id}.
        Updates only the `notes` column on the row. Pass `null` to clear
        notes. unit_price / effective_from / effective_to remain immutable.
        Issue #954.
      properties:
        notes:
          type: string
          nullable: true
    Contact:
      type: object
      description: A person associated with one or more entities.
      properties:
        id:
          type: string
          format: uuid
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
        title:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        mobile:
          type: string
          nullable: true
        is_primary:
          type: boolean
        status:
          type: string
          enum:
            - active
            - inactive
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
        updated_by:
          type: string
          format: uuid
          nullable: true
    ContactWithRole:
      type: object
      description: |
        Contact + the association row that ties it to a specific entity.
        Returned by `GET /api/v1/contacts/{entityType}/{entityID}` so the
        client knows the role + the association id (needed to disassociate).
      allOf:
        - $ref: '#/components/schemas/Contact'
        - type: object
          properties:
            association_id:
              type: string
              format: uuid
            role:
              type: string
              nullable: true
              description: |
                Free-form role on this association. Well-known values:
                `primary`, `direct_ship`, `billing`, `shipping`, `portal`.
    ContactCreateRequest:
      type: object
      required:
        - last_name
      properties:
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
        title:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        mobile:
          type: string
          nullable: true
        is_primary:
          type: boolean
          default: false
        notes:
          type: string
          nullable: true
        role:
          type: string
          description: |
            Optional role on the association created with this contact.
            Free-form, but the well-known values listed in the Contacts
            tag are what downstream features look up.
    ContactUpdateRequest:
      type: object
      required:
        - last_name
      properties:
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
        title:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        mobile:
          type: string
          nullable: true
        is_primary:
          type: boolean
        notes:
          type: string
          nullable: true
    StoredEvent:
      type: object
      description: One row from event_outbox as returned by GET /api/v1/events.
      properties:
        id:
          type: string
          format: uuid
        event_type:
          type: string
          description: Wire-level event type, e.g. "order.shipped.v1"
        aggregate_type:
          type: string
          description: Coarse entity category, e.g. "order"
        aggregate_id:
          type: string
          format: uuid
        occurred_at:
          type: string
          format: date-time
        payload:
          type: object
          description: |
            Consumer-facing event body. Shape depends on event_type.
            Currently defined: order.shipped.v1 (OrderShippedV1Payload),
            order.blocked.v1 (OrderBlockedV1Payload), order.block_resolved.v1
            (OrderBlockResolvedV1Payload).
          additionalProperties: true
    OrderShippedV1Payload:
      type: object
      description: |
        Payload for event_type = "order.shipped.v1". Denormalized so n8n
        consumers can build the downstream payload (e.g. Repfabric sales
        activity) without a callback into ConsignTrak.
      properties:
        order_id:
          type: string
          format: uuid
        order_number:
          type: integer
          format: int64
        release_number:
          type: integer
          format: int64
          nullable: true
        original_release_number:
          type: integer
          format: int64
          nullable: true
        customer_po_number:
          type: string
        manufacturer:
          type: object
          properties:
            id:
              type: string
              format: uuid
            code:
              type: string
            name:
              type: string
        customer:
          type: object
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            account_number:
              type: string
        ship_to:
          type: object
          properties:
            name:
              type: string
            address_1:
              type: string
            address_2:
              type: string
            city:
              type: string
            state:
              type: string
            zip:
              type: string
        shipped_at:
          type: string
          format: date-time
        shipped_by_user_id:
          type: string
          format: uuid
        audit_chain_index:
          type: integer
          format: int64
          description: |
            Per-manufacturer audit-chain coordinate (max chain_seq across the
            inventory_movements rows written by this shipment). Zero is the
            sentinel for "no warehouse-chain coordinate available" — the case
            for a pure-direct shipment whose lines never touched warehouse
            stock. Consumers ordering on this field within a manufacturer's
            stream should fall back to occurred_at when zero is encountered.
        lines:
          type: array
          items:
            type: object
            properties:
              line_id:
                type: string
                format: uuid
              item_number_display:
                type: string
              item_description:
                type: string
              qty_shipped:
                type: integer
              qty_warehouse:
                type: integer
              qty_direct:
                type: integer
              unit_price:
                type: number
                format: double
              extended_price:
                type: number
                format: double
              value:
                type: number
                format: double
                nullable: true
                description: |
                  Explicit pass-through pricing signal. Populated when
                  extended_price > 0; omitted (null) otherwise so consumers
                  can distinguish "no value carried" from "free of charge".
    OrderBlockedV1Payload:
      type: object
      description: |
        Payload for event_type = "order.blocked.v1" (issue #1355). Emitted
        when a pick exception (short pick, mispick, or blocking quality
        flag) creates an order block that pauses pack/ship. Subscribers
        (n8n → supply-partner email / Repfabric) use it to tell the
        principal about the inventory issue the moment it happens.
        Denormalized — no callback into ConsignTrak needed.
      properties:
        block_id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - short_pick
            - mispick
            - quality
        order_id:
          type: string
          format: uuid
        order_number:
          type: integer
          format: int64
        release_number:
          type: integer
          format: int64
          nullable: true
        customer_po_number:
          type: string
        manufacturer:
          type: object
          properties:
            id:
              type: string
              format: uuid
            code:
              type: string
            name:
              type: string
        customer:
          type: object
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            account_number:
              type: string
        blocked_at:
          type: string
          format: date-time
        blocked_by_user_id:
          type: string
          format: uuid
        exception:
          type: object
          description: |
            Per-kind detail. Item fields are omitted for batch-level quality
            flags; qty_short/short_reason are short-pick only; the actual_*
            fields are mispick only and omitted when the packer could not
            identify the item actually in the carton; flag_reason/flag_note
            cover mispick and quality.
          properties:
            exception_id:
              type: string
              format: uuid
            item_number_display:
              type: string
            item_description:
              type: string
            qty_short:
              type: integer
              nullable: true
            short_reason:
              type: string
            actual_item_number_display:
              type: string
            actual_qty:
              type: integer
              nullable: true
            flag_reason:
              type: string
            flag_note:
              type: string
    OrderBlockResolvedV1Payload:
      type: object
      description: |
        Payload for event_type = "order.block_resolved.v1" (issue #1355).
        Emitted when an order block's gate lifts — the all-clear for the
        matching order.blocked.v1. Outcome "resolved" means the office
        chose a disposition; "cancelled" means the picker self-corrected
        (disposition omitted, note carrying the supersede reason).
      properties:
        block_id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - short_pick
            - mispick
            - quality
        order_id:
          type: string
          format: uuid
        order_number:
          type: integer
          format: int64
        release_number:
          type: integer
          format: int64
          nullable: true
        customer_po_number:
          type: string
        manufacturer:
          type: object
          properties:
            id:
              type: string
              format: uuid
            code:
              type: string
            name:
              type: string
        customer:
          type: object
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            account_number:
              type: string
        outcome:
          type: string
          enum:
            - resolved
            - cancelled
        disposition:
          type: string
          description: >-
            Resolution disposition (ship_short_backorder, ship_as_is,
            repick_required, direct_ship_full_remainder, cancel_order, other).
            Omitted for outcome=cancelled.
        note:
          type: string
        resolved_at:
          type: string
          format: date-time
        resolved_by_user_id:
          type: string
          format: uuid
    ShipmentEventEnvelope:
      type: object
      description: |
        One row from the shipment-event surface (GET /api/v1/shipments/events,
        issue #868). Sibling fields beyond event_id/payload surface the
        tenant scope and chain coordinate directly so downstream
        commission/ICM consumers can filter and reconcile without cracking
        the payload.
      properties:
        event_id:
          type: string
          format: uuid
        event_type:
          type: string
          description: >-
            Always "order.shipped.v1" today; future shipment-bearing types are
            added via events.ShipmentEventTypes.
          example: order.shipped.v1
        aggregate_type:
          type: string
          example: order
        aggregate_id:
          type: string
          format: uuid
          description: >-
            The order id. CT models a shipment as an order in terminal status;
            aggregate_id IS the shipment identifier.
        occurred_at:
          type: string
          format: date-time
        manufacturer_id:
          type: string
          format: uuid
        audit_chain_index:
          type: integer
          format: int64
          description: |
            Same value as the embedded payload's audit_chain_index — surfaced
            at the envelope level for filter/reconcile use without cracking
            the payload. Zero is the "no chain coordinate" sentinel; see
            OrderShippedV1Payload.audit_chain_index.
        payload:
          allOf:
            - $ref: '#/components/schemas/OrderShippedV1Payload'
    ShipmentEventListResponse:
      type: object
      description: |
        Wire envelope for GET /api/v1/shipments/events. data is always a
        JSON array (possibly empty, never null). next_cursor is a non-empty
        opaque string when the result page filled the limit; pass it back
        as ?since=<cursor> to fetch the next page. Null on the last page.
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ShipmentEventEnvelope'
        next_cursor:
          type: string
          nullable: true
          description: Opaque continuation token. Treat as a string; do not parse.
    WebhookSubscription:
      type: object
      description: |
        Public view of a webhook_subscriptions row. Returned from list /
        get / delete. Notably NOT including the signing_secret — see
        WebhookSubscriptionWithSecret for the once-per-secret response.
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
        event_types:
          type: array
          items:
            type: string
            example: order.shipped.v1
        manufacturer_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            Tenant scope. NULL = admin-wide subscription (only allowed for
            non-mfg-scoped API keys).
        is_active:
          type: boolean
        description:
          type: string
        max_attempts:
          type: integer
          description: >-
            Per-subscription retry ceiling. After this many transient failures
            the delivery is dead-lettered.
          default: 8
          minimum: 1
          maximum: 24
        signing_secret_version:
          type: integer
          description: >-
            Increments each time the secret is rotated. Reserved for future
            overlapping-window verification schemes.
          minimum: 1
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
        updated_by:
          type: string
          format: uuid
          nullable: true
    WebhookSubscriptionCreate:
      type: object
      required:
        - url
        - event_types
      description: Body of POST /api/v1/webhooks.
      properties:
        url:
          type: string
          format: uri
          description: >-
            HTTPS receiver URL. http:// is permitted in dev but production
            should always use https.
        event_types:
          type: array
          minItems: 1
          items:
            type: string
            example: order.shipped.v1
          description: |
            Event types this subscription should receive. Must be non-empty,
            and every value must be a known event type — an unknown or
            typo'd value is rejected with 422 `unknown_event_type` (it would
            otherwise be accepted and silently never deliver). Enumerate the
            valid values with `GET /api/v1/webhooks/event-types`. Current
            catalog: `order.block_resolved.v1`, `order.blocked.v1`,
            `order.direct_cancelled.v1`, `order.direct_notified.v1`,
            `order.direct_released.v1`, `order.shipped.v1`,
            `period.closed.v1`.
        manufacturer_id:
          type: string
          format: uuid
          description: >
            Optional. Admin keys may set this to scope the subscription to one
            supply partner.

            Manufacturer-scoped keys: omit (auto-filled from key scope) or pass
            the same value

            as the key's scope (mismatched values return 403).
        description:
          type: string
          description: Free-form note for operators ("Repfabric n8n connector — prod").
        max_attempts:
          type: integer
          minimum: 1
          maximum: 24
          default: 8
          description: Per-subscription override of the global max_attempts. Reject > 24.
    WebhookSubscriptionWithSecret:
      allOf:
        - $ref: '#/components/schemas/WebhookSubscription'
        - type: object
          required:
            - signing_secret
          properties:
            signing_secret:
              type: string
              description: |
                Plaintext signing secret. Returned ONCE on POST and on
                rotate-secret. Persist it on the receiver immediately and
                never log it; subsequent reads via list / get will not
                include it. After rotate-secret the previous value is
                unrecoverable.
    AdminUser:
      type: object
      description: User record returned by admin endpoints.
      properties:
        id:
          type: string
          format: uuid
        username:
          type: string
        email:
          type: string
          nullable: true
        display_name:
          type: string
        role_id:
          type: string
          format: uuid
        role:
          type: string
          description: Role name (e.g. system_admin, office, consignor).
        status:
          type: string
          enum:
            - active
            - inactive
        mfa_enabled:
          type: boolean
        manufacturer_id:
          type: string
          nullable: true
          format: uuid
        manufacturer_name:
          type: string
          nullable: true
    ConfigBundle:
      type: object
      description: |
        Post-update view of system config. Numeric/boolean settings are
        stored as strings in `system_config`; the API surfaces them in
        their stored form so callers can round-trip values without
        guessing a coerced type.
      properties:
        order_seq:
          type: integer
          format: int64
        release_seq:
          type: integer
          format: int64
        cycle_count_items_per_batch:
          type: string
        require_cycle_count_before_orders:
          type: string
          enum:
            - 'true'
            - 'false'
        document_retention_years:
          type: string
        receiving_stuck_threshold_hours:
          type: string
        price_visibility_enabled:
          type: boolean
        org_name:
          type: string
          description: >-
            Operating company / sender display name used in outbound email
            signatures and direct-ship notifications. Defaults to "ConsignTrak"
            when unset or empty.
    EODSummary:
      type: object
      required:
        - orders
        - supply_partners
        - units
        - batches_pending_tracking
      description: |
        Top-of-page totals for the End-of-Day Shipment report. `orders`
        is "orders with shipping activity on the selected day" — a
        partial-ship order with batches on two civil days appears in
        both days' summaries.
      properties:
        orders:
          type: integer
          description: >-
            Distinct orders with at least one warehouse-fulfilled line that
            physically shipped today.
        supply_partners:
          type: integer
          description: Distinct supply partners (manufacturers) represented in `orders`.
        units:
          type: integer
          description: >-
            Sum of `qty_shipped` across every warehouse-fulfilled line in the
            report.
        batches_pending_tracking:
          type: integer
          description: >-
            Shipped pick batches included in today's orders that carry no live
            box yet (no tracking captured).
    EODManufacturerSection:
      type: object
      required:
        - manufacturer_id
        - manufacturer_name
        - manufacturer_code
        - orders
      properties:
        manufacturer_id:
          type: string
          format: uuid
        manufacturer_name:
          type: string
        manufacturer_code:
          type: string
        orders:
          type: array
          items:
            $ref: '#/components/schemas/EODOrder'
    EODOrder:
      type: object
      required:
        - order_id
        - order_number
        - shipped_at
        - lines
      properties:
        order_id:
          type: string
          format: uuid
        order_number:
          type: integer
        release_number:
          type: integer
        original_release_number:
          type: integer
        customer_po_number:
          type: string
        customer_name:
          type: string
        shipped_at:
          type: string
          format: date-time
        ship_via_code:
          type: string
        ship_via_description:
          type: string
        ship_via_carrier_name:
          type: string
        lines:
          type: array
          items:
            $ref: '#/components/schemas/EODLine'
        batches:
          type: array
          items:
            $ref: '#/components/schemas/EODBatchSummary'
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/EODAttachment'
    EODLine:
      type: object
      required:
        - line_number
        - item_id
        - item_number
        - qty_shipped
      properties:
        line_number:
          type: integer
        item_id:
          type: string
          format: uuid
          description: Internal item UUID — used by the UI to link to the item detail page.
        item_number:
          type: string
        item_description:
          type: string
        qty_ordered:
          type: integer
        qty_shipped:
          type: integer
          description: >-
            Warehouse portion shipped. On split lines this is less than
            `qty_ordered`.
    EODBatchSummary:
      type: object
      required:
        - pick_batch_id
        - batch_number
      properties:
        pick_batch_id:
          type: string
          format: uuid
          description: >-
            Internal pick_batches.id — stable anchor for tracking-copy
            affordances in the UI.
        batch_number:
          type: string
        tracking_number:
          type: string
          description: >-
            Primary box (lowest-seq live parcel). Empty when the office hasn't
            captured tracking yet. See `parcels` for all boxes.
        tracking_carrier:
          type: string
          enum:
            - ''
            - ups
            - fedex
            - usps
            - other
        parcels:
          type: array
          description: >-
            Every live box on this batch (multi-box shipping), ordered by box
            number. Empty when the batch shipped without tracking.
          items:
            $ref: '#/components/schemas/EODParcelSummary'
    EODParcelSummary:
      type: object
      required:
        - parcel_seq
        - tracking_number
        - tracking_carrier
      properties:
        parcel_seq:
          type: integer
          description: 1-based box number within the shipment.
        tracking_number:
          type: string
        tracking_carrier:
          type: string
          enum:
            - ups
            - fedex
            - usps
            - other
    EODAttachment:
      type: object
      required:
        - document_id
        - file_name
      properties:
        document_id:
          type: string
          format: uuid
        file_name:
          type: string
        file_type:
          type: string
          description: MIME type.
        description:
          type: string
    CarrierCredentialInput:
      type: object
      description: |
        Variant-tagged write payload for carrier credentials. Exactly one
        variant's fields must be populated based on the path's `{carrier}`
        segment. Unknown fields are rejected (400) so an operator mistake
        (e.g. posting `hmac_secret` to a UPS row) surfaces early.
      properties:
        hmac_secret:
          type: string
          description: |
            FedEx variant. Shared signing secret FedEx publishes for the
            AIV webhook subscription. Required when {carrier}=fedex.
        oauth_client_id:
          type: string
          description: UPS variant. OAuth client ID. Required when {carrier}=ups.
        oauth_client_secret:
          type: string
          description: UPS variant. OAuth client secret. Required when {carrier}=ups.
        ups_shipper_number:
          type: string
          description: |
            UPS variant. UPS Shipper Number that scopes Tracking-by-reference
            queries. Required when {carrier}=ups.
        poll_interval_seconds:
          type: integer
          minimum: 30
          maximum: 3600
          description: |
            Optional poll-cadence override (seconds). Defaults to 60. Only
            consulted by the UPS poller; ignored on FedEx rows.
    CarrierCredentialOutput:
      type: object
      description: |
        Read-side projection. Never includes secrets — secrets are only
        ever returned (once) in the rotate response.
      properties:
        manufacturer_id:
          type: string
          format: uuid
        carrier:
          type: string
          enum:
            - fedex
            - ups
        configured:
          type: boolean
        masked_client_id:
          type: string
          description: |
            UPS variant only. First 4 characters of the OAuth client ID
            followed by mask characters. Empty for FedEx rows.
        ups_shipper_number:
          type: string
          description: UPS variant only. Plaintext shipper number (not a secret).
        poll_interval_seconds:
          type: integer
        last_used_at:
          type: string
          format: date-time
          nullable: true
        configured_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CarrierCredentialReveal:
      allOf:
        - $ref: '#/components/schemas/CarrierCredentialOutput'
        - type: object
          properties:
            hmac_secret_reveal_once:
              type: string
              description: |
                Plaintext HMAC secret. **This is the only response that
                will ever include this value.** Returned on rotate (FedEx
                variant) only. Subsequent reads omit it; to obtain a fresh
                secret the caller must rotate again.
            oauth_client_secret_reveal_once:
              type: string
              description: |
                Plaintext UPS OAuth client secret. **This is the only
                response that will ever include this value.** Returned on
                rotate (UPS variant) only.
  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
          example:
            error:
              code: unauthorized
              message: API key required
    Forbidden:
      description: Insufficient permissions
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
          example:
            error:
              code: forbidden
              message: insufficient permissions
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
    BadRequest:
      description: >-
        Malformed request (invalid JSON, missing required field, or out-of-range
        value)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
          example:
            error:
              code: rate_limited
              message: too many requests
paths:
  /api/v1/items:
    get:
      tags:
        - Items
      summary: List items
      description: |
        Paginated list of items. Supply-partner-scoped keys only see their
        supply partner's items. Filter / status / category accept repeated
        values (`?filter=A&filter=B`); single-value calls keep working as
        one-element arrays. Search hits item number, description, and
        alias normalized values (#1021).
      parameters:
        - name: manufacturer
          in: query
          schema:
            type: string
          description: Filter by supply partner ID (ignored for supply-partner-scoped keys)
        - name: status
          in: query
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum:
                - active
                - discontinued
                - inactive
          description: |
            One or more item statuses. Defaults to `active` when omitted.
            Repeat the param to combine: `?status=active&status=discontinued`.
        - name: filter
          in: query
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum:
                - negative
                - oversold
                - out_of_stock
                - below_reorder
                - missing_price
          description: |
            Stock anomaly filters. Multiple values OR together
            (`?filter=out_of_stock&filter=below_reorder` returns items
            matching either bucket).
        - name: category
          in: query
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
          description: Filter by `items.category`. Multiple values OR together.
        - name: location
          in: query
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
          description: >-
            Filter by `items.warehouse_location`. Multiple values OR together.
            (#1021 Phase 2)
        - name: uom
          in: query
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
          description: >-
            Filter by `items.selling_uom`. Multiple values OR together. (#1021
            Phase 2)
        - name: movement
          in: query
          schema:
            type: string
            enum:
              - 30d
              - 90d
              - 180d
              - 365d
          description: |
            Restricts to items with no movement in at least N days (including
            items that have never moved — `date_last_movement IS NULL`).
            Unknown tokens are ignored. (#1021 Phase 2)
        - name: velocity
          in: query
          schema:
            type: string
            enum:
              - fast
              - slow
              - dead
          description: |
            Velocity bucket over a 365-day window of outbound shipments:
            `fast` = top 25%, `slow` = bottom 25% (non-zero), `dead` = no
            shipments. Unknown tokens are ignored. (#1021 Phase 2)
        - name: sort
          in: query
          schema:
            type: string
            enum:
              - mfg_code
              - item_number
              - description
              - on_hand_asc
              - on_hand_desc
              - available_asc
              - last_movement_desc
          description: Sort order. Unknown values fall back to `mfg_code`.
        - name: q
          in: query
          schema:
            type: string
          description: Search query (item number, description, or item alias normalized).
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        '200':
          description: List of items with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    nullable: true
                    description: >-
                      null, not [], when the result set is empty. List rows
                      populate a subset of Item fields — see the Item schema.
                    items:
                      $ref: '#/components/schemas/Item'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Items
      summary: Create item (Phase 3a)
      description: >
        Creates a new item. Requires `inventory.edit`. Supply-partner-scoped
        keys

        can only create items for their own supply partner (403 otherwise).


        Returns 409 `duplicate_item_number` when an item with the same

        manufacturer + display number already exists.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - manufacturer_id
                - item_number_display
                - description
              properties:
                manufacturer_id:
                  type: string
                  format: uuid
                item_number_display:
                  type: string
                description:
                  type: string
                description_extended:
                  type: string
                category:
                  type: string
                selling_uom:
                  type: string
                  default: EA
                purchase_uom:
                  type: string
                warehouse_location:
                  type: string
                unit_weight:
                  type: number
      responses:
        '201':
          description: Created item
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      item:
                        $ref: '#/components/schemas/Item'
        '400':
          description: Missing required fields or invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Duplicate item number for this manufacturer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/items/search:
    get:
      tags:
        - Items
      summary: Search items by part number
      description: |
        Fuzzy search via pg_trgm. Returns up to 20 matches. Match strategy is
        two-level: exact normalized match first, then prefix/substring, then
        trigram fuzzy. Result keys are PascalCase (the Go struct has no json
        tags) — see the ItemSearchResult schema.
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
        - name: manufacturer
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    nullable: true
                    description: null, not [], when there are no matches.
                    items:
                      $ref: '#/components/schemas/ItemSearchResult'
        '400':
          description: Missing q parameter
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/items/{id}:
    get:
      tags:
        - Items
      summary: Get item detail
      description: Returns 404 for items outside the key's supply partner scope.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Item detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Item'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Items
      summary: Update item (Phase 3a)
      description: >
        Updates editable item fields. Requires `inventory.edit`.
        Supply-partner-scoped

        keys can only update their own supply partner's items (404 cross-mfg).


        PATCH semantics: omitted fields preserve the existing value; present
        fields

        write the supplied value. Empty strings clear NULLable fields (matches
        the

        web form's NULLIF handling). `item_number_display` and `manufacturer_id`

        are immutable.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                description:
                  type: string
                description_extended:
                  type: string
                category:
                  type: string
                selling_uom:
                  type: string
                purchase_uom:
                  type: string
                warehouse_location:
                  type: string
                unit_weight:
                  type: number
                reorder_level:
                  type: integer
                  minimum: 0
                  description: |
                    Issue #953. Omitted preserves the existing value; present
                    writes it. Negative values are rejected with 400. The
                    same field is editable by supply partners through the
                    consignor portal at `POST /portal/inventory/{id}/field`
                    when their org has `portal_inventory_edit_enabled = true`.
      responses:
        '200':
          description: Updated item
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      item:
                        $ref: '#/components/schemas/Item'
        '400':
          description: Invalid body or empty description
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/items/{id}/discontinue:
    post:
      tags:
        - Items
      summary: Discontinue item (Phase 3a)
      description: |
        Marks an item as discontinued. Optionally records a successor — the
        successor must belong to the same manufacturer, must be active, and
        cannot be the item itself.

        When the discontinued item still has stock on hand, the response
        includes a structured `stock_warning` object (the web equivalent
        surfaces this as a `?warning=` query string on the post-redirect URL).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                superseded_by:
                  type: string
                  format: uuid
                  description: Optional successor item id
                reason:
                  type: string
      responses:
        '200':
          description: Discontinued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      item:
                        $ref: '#/components/schemas/Item'
                      stock_warning:
                        type: object
                        properties:
                          qty_on_hand:
                            type: integer
        '400':
          description: Self-supersede or invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Item or successor not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '409':
          description: Already discontinued, or supersede alias conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: Manufacturer mismatch or successor not active
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/items/{id}/reactivate:
    post:
      tags:
        - Items
      summary: Reactivate discontinued item (Phase 3a)
      description: Returns the active item. Requires `inventory.edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Reactivated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      item:
                        $ref: '#/components/schemas/Item'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Reactivation refused
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/items/{id}/locations:
    get:
      tags:
        - Items
      summary: List the bins holding an item's stock (#1396)
      description: |
        Bin-level stock for one item. Requires `inventory.view`. Supply-partner-
        scoped actors get 404 for items belonging to another supply partner
        (existence is not leaked).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The item's bin-level stock.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      item_id:
                        type: string
                        format: uuid
                      locations:
                        type: array
                        items:
                          $ref: '#/components/schemas/ItemLocation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/ship-via-codes:
    get:
      tags:
        - Orders
      summary: List ship-via carriers with mode
      description: |
        Read-only reference list of active ship-via carriers (#1411). Use the
        `id` as `ship_via_id` when creating or patching orders, and `mode`
        (`parcel` / `ltl` / `will_call`) to classify orders the same way the
        warehouse pick / pack / ship pill does. Carriers are global (not
        supply-partner scoped). Ordered UPS, FedEx, LTL, Will Call, then any
        other carriers alphabetically.

        Requires `orders.view` permission.
      responses:
        '200':
          description: Active ship-via carriers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ShipViaCode'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/orders:
    get:
      tags:
        - Orders
      summary: List orders
      description: |
        Paginated list of orders. Supply-partner-scoped keys only see their
        supply partner's orders.

        Each order in `data` carries a `display_substatus` field (issue #1067)
        when the order has an active pick batch, whatever the order's status.
        Values: `"picking"` (a batch is released or in progress),
        `"picked"` (the least-progressed active batch is `completed`), or
        `"packed"` (every active batch is `packed`). The field is omitted
        for orders with no active batch.

        Triage filters (#1416) mirror the office orders list: `q` is a
        case-insensitive substring match across order number, customer PO,
        customer name, release number, and original release number — the
        way to find "order 130664" or "PO 244875C03" from a support report.
        `status` and `priority` accept repeated params or comma lists;
        `status=open` expands to `entered,released,partial`. Unknown
        `status`/`priority` values and non-positive `aged_hours` are a 400,
        never silently dropped.
      parameters:
        - name: manufacturer
          in: query
          schema:
            type: string
        - name: customer
          in: query
          schema:
            type: string
        - name: status
          in: query
          description: >-
            Repeatable / comma-separated. One of `open` (macro), `entered`,
            `selected`, `released`, `partial`, `shipped`, `billed`, `closed`,
            `cancelled`. Combined with `awaiting_direct_ship` by OR (union),
            matching the UI's status pills.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum:
                - open
                - entered
                - selected
                - released
                - partial
                - shipped
                - billed
                - closed
                - cancelled
        - name: q
          in: query
          description: >-
            Substring search over order number, customer PO, customer name,
            release number, original release number (#1416).
          schema:
            type: string
        - name: priority
          in: query
          description: Repeatable / comma-separated; `normal`, `high`, `rush` (#1416).
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum:
                - normal
                - high
                - rush
        - name: aged_hours
          in: query
          description: >-
            Only orders created at least this many hours ago (#1416). Must be a
            positive integer.
          schema:
            type: integer
            minimum: 1
        - name: awaiting_direct_ship
          in: query
          description: >-
            `1` restricts to released/partial orders with a direct-ship line not
            yet shipped (#1416). OR-combined with `status`.
          schema:
            type: string
            enum:
              - '1'
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 25
            maximum: 100
      responses:
        '200':
          description: List of orders with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    description: >-
                      [] when the result set is empty (was null before #1416).
                      List rows populate a subset of Order fields — see the
                      Order schema; `priority` is populated on list rows since
                      #1416.
                    items:
                      $ref: '#/components/schemas/Order'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Orders
      summary: Create order atomically (header + lines)
      description: |
        Creates an order with all lines in a single transaction. Requires both
        `orders.create` and `orders.edit` permissions. If any line fails
        validation, the entire order is rolled back.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
      responses:
        '201':
          description: Order created with all lines
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation or business logic error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/orders/{id}:
    get:
      tags:
        - Orders
      summary: Get order detail with lines (+ optional triage context)
      description: |
        Default payload is `{order, lines}` and is unchanged. `include`
        (#1416, repeatable or comma-separated) adds the context the HTML
        order page shows, one top-level key per value:

        - `fulfillment` — display sub-status, warehouse/direct split, pending
          direct-ship lines, and the lifecycle timeline. `lifecycle.batch_id`
          is the hook into `GET /api/v1/pick-batches/{batchID}`.
        - `shipments` — every pick batch with tracking + parcels (same shape
          as `GET /api/v1/orders/{orderID}/pick-batches`).
        - `blocks` — open order blocks, each with a summary of the exception
          behind it; the full row is `GET /api/v1/exceptions/...`.
        - `documents` — order attachments plus the current packing list.

        An unknown include value is a 400. Supply-partner-scoped actors get
        404 for another supply partner's order before any include runs.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: include
          in: query
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum:
                - fulfillment
                - shipments
                - blocks
                - documents
      responses:
        '200':
          description: Order detail with lines, plus any requested includes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
                      fulfillment:
                        $ref: '#/components/schemas/OrderFulfillment'
                      shipments:
                        type: array
                        description: Present only with `include=shipments`.
                        items:
                          $ref: '#/components/schemas/OrderPickBatch'
                      blocks:
                        type: array
                        description: >-
                          Present only with `include=blocks`. `[]` when the
                          order has no open blocks.
                        items:
                          $ref: '#/components/schemas/OrderBlock'
                      documents:
                        $ref: '#/components/schemas/OrderDocuments'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Orders
      summary: Update order header (Phase 3e)
      description: |
        PATCH semantics — omitted fields preserve. Empty strings are normalized
        to nil so the service's `COALESCE($N, existing)` preserves the column;
        explicit non-empty strings overwrite. The `priority` field is optional
        and applied via SetOrderPriority (separate audit row when it changes).
        Returns the reloaded order + lines.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                customer_po_number:
                  type: string
                  nullable: true
                manufacturer_auth:
                  type: string
                  nullable: true
                freight_terms:
                  type: string
                  nullable: true
                  description: >-
                    Setting ship_via_id to the Will Call carrier forces this to
                    "WILL CALL" (#1369).
                carrier_account:
                  type: string
                  nullable: true
                shipping_point:
                  type: string
                  nullable: true
                special_instructions:
                  type: string
                  nullable: true
                priority:
                  type: string
                  enum:
                    - normal
                    - high
                    - rush
                ship_via_id:
                  type: string
                  format: uuid
                  nullable: true
                ship_method:
                  type: string
                  nullable: true
                ship_via_other:
                  type: string
                  nullable: true
                third_party_payer_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Snapshots the payer into bill_to_* + carrier_account.
                bill_to_name:
                  type: string
                  nullable: true
                bill_to_address_1:
                  type: string
                  nullable: true
                bill_to_address_2:
                  type: string
                  nullable: true
                bill_to_city:
                  type: string
                  nullable: true
                bill_to_state:
                  type: string
                  nullable: true
                bill_to_zip:
                  type: string
                  nullable: true
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
        '400':
          description: Invalid body or invalid priority
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Order is not in an editable status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/release:
    post:
      tags:
        - Orders
      summary: Release an order
      description: |
        Transitions an `entered` order to `released`. Releasing also stamps the
        per-supply-partner release sequence and the global original-release
        sequence on the order. Requires orders.release permission. Manufacturer-
        scoped API keys can only release orders on their own supply partner.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Order released; returns the updated order and lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: >
            Order cannot be released. `code` is one of: `invalid_status` (not in
            a

            releasable status) or `will_call_all_direct` (a Will Call order
            whose

            lines are all direct-ship has no warehouse quantity to pick up,
            #1369).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/cancel:
    post:
      tags:
        - Orders
      summary: Cancel an order
      description: |
        Transitions an order to `cancelled`. Allowed from any pre-shipped
        status. Requires orders.edit permission and (for scoped keys) matching
        supply partner.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Order cancelled; returns the updated order and lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: >-
            Order is past the point where it can be cancelled (already
            shipped/closed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/close:
    post:
      tags:
        - Orders
      summary: Close a shipped order
      description: |
        Transitions a `shipped` order to `closed`. Closed orders are read-only.
        Requires orders.edit permission and (for scoped keys) matching supply
        partner.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Order closed; returns the updated order and lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Order is not in `shipped` status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/priority:
    post:
      tags:
        - Orders
      summary: Set order priority
      description: |
        Sets the order priority to one of `normal`, `high`, or `rush`. Priority
        is editable until the order reaches a terminal status (shipped/billed/
        closed/cancelled). Requires orders.edit permission and (for scoped
        keys) matching supply partner.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetPriorityRequest'
      responses:
        '200':
          description: Priority updated; returns the updated order and lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
        '400':
          description: Missing or invalid priority value.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Order is past the point where priority can change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/ltl-tracking:
    post:
      tags:
        - Orders
      summary: Record LTL freight carrier + PRO# on a shipped order
      description: |
        Records (or clears) the LTL freight carrier and PRO# on a post-ship
        order. LTL freight info — signed Bill of Lading, PRO# — arrives after
        the truck leaves and falls outside the parcel-carrier tracking
        ingestion (UPS/FedEx). Allowed only on orders in a post-ship state
        (`shipped`, `partial`, `closed`). Both body fields are optional and
        recorded independently; submitting both blank clears any prior record.
        Requires orders.edit permission and (for scoped keys) matching supply
        partner.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecordLTLTrackingRequest'
      responses:
        '200':
          description: LTL tracking recorded; returns the updated order and lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
        '400':
          description: Invalid JSON body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Order is not in a post-ship state (shipped/partial/closed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/lines:
    post:
      tags:
        - Orders
      summary: Add a line to an order (Phase 3e)
      description: |
        Adds one line. 422 `exception_blocked` when the item has an open pick
        exception (audit in progress); re-POST with `acknowledge_exception=true`
        to override. 422 `negative_stock` when warehouse allocation would
        drive `qty_available` negative; re-POST with `acknowledge_negative=true`
        to override. 422 `invalid_split` when warehouse + direct ≠ qty_ordered.
        Direct-ship lines never trigger `exception_blocked` or `negative_stock`.
        Returns 201 with the reloaded order + lines.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - item_id
                - qty_ordered
              properties:
                item_id:
                  type: string
                  format: uuid
                qty_ordered:
                  type: integer
                  minimum: 1
                  maximum: 100000000
                unit_price:
                  type: number
                  minimum: 0
                  maximum: 99999999
                  description: 400 invalid_price outside 0..99,999,999
                discount_pct:
                  type: number
                fulfillment_source:
                  type: string
                  enum:
                    - warehouse
                    - direct
                    - split
                  default: warehouse
                qty_warehouse:
                  type: integer
                qty_direct:
                  type: integer
                acknowledge_negative:
                  type: boolean
                  default: false
                acknowledge_exception:
                  type: boolean
                  default: false
                  description: Override the open-pick-exception gate (#1352)
      responses:
        '201':
          description: Line added; returns reloaded order + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order:
                        $ref: '#/components/schemas/Order'
                      lines:
                        type: array
                        nullable: true
                        description: >-
                          null when the order has no lines; create responses
                          always carry at least one.
                        items:
                          $ref: '#/components/schemas/OrderLine'
        '400':
          description: Missing item_id or qty_ordered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: >-
            Open pick exception without ack, negative stock without ack, invalid
            split, or non-editable status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/lines/{lineID}:
    delete:
      tags:
        - Orders
      summary: Remove an order line (Phase 3e)
      description: |
        Returns 204 on success. 404 when the line does not belong to the URL's
        order, or when either order or line does not exist. 422 `invalid_status`
        when the order is no longer editable.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: lineID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Line removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Order is not in an editable status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/direct-ship/notify:
    post:
      tags:
        - Orders
      summary: Stamp direct-ship notify (via=email) (Phase 3e)
      description: |
        Stamps `direct_notified_*` on the supplied lines (which must all
        belong to the URL's order). The service emits an outbox event for the
        integration lane (n8n / external mailer) — this endpoint does not send
        email itself, matching the web flow.

        `note` is accepted for symmetry with the web form but **not persisted**;
        the web embeds it client-side into the mailto URL only.

        Refused (400 `no_email_contact`) when the order's supply partner lacks
        a direct-ship contact with an email — same gate the web enforces.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - line_ids
              properties:
                line_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
                note:
                  type: string
                  description: Accepted for forward-compat; not persisted.
      responses:
        '200':
          description: Notified; returns the updated DirectShipRow values.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rows:
                        type: array
                        items:
                          type: object
        '400':
          description: >
            `bad_request` (empty/unknown line_ids), `cross_order` (line not on

            this order), or `no_email_contact` (partner lacks direct-ship
            email).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: One or more lines have already shipped direct
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: No direct portion or order in invalid status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/orders/{id}/direct-ship/mark-noted:
    post:
      tags:
        - Orders
      summary: Stamp direct-ship notify (via=out_of_band) (Phase 3e)
      description: |
        Same shape as `/direct-ship/notify` but for via=`out_of_band` (stamps
        the partner was notified by phone, in-person, etc.). Skips the email
        contact validation. `note` is accepted but not persisted.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - line_ids
              properties:
                line_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
                note:
                  type: string
      responses:
        '200':
          description: Marked; returns the updated DirectShipRow values.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rows:
                        type: array
                        items:
                          type: object
        '400':
          description: Empty/unknown line_ids or cross_order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: One or more lines have already shipped direct
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: No direct portion or order in invalid status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/customers:
    get:
      tags:
        - Customers
      summary: List customers
      description: >-
        Paginated list of customers. Customers are global (not
        supply-partner-scoped).
      parameters:
        - name: q
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        '200':
          description: List of customers with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - Customers
      summary: Create a customer
      description: |
        Creates a new customer record. Requires customers.edit permission.
        Customers are global — no supply partner scoping applies.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCustomerRequest'
      responses:
        '201':
          description: Customer created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Full customer record
        '400':
          description: Missing account_number or name, or malformed JSON
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Duplicate account_number or service-level validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/customers/search:
    get:
      tags:
        - Customers
      summary: Search customers
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
        '400':
          description: Missing q parameter
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/customers/{id}:
    get:
      tags:
        - Customers
      summary: Get customer detail
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Customer detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Customers
      summary: Update a customer
      description: |
        Updates an existing customer. Requires customers.edit permission.
        Empty strings clear NULLable fields (matches the web form's NULLIF
        behavior). The account_number and status fields are not editable
        through this endpoint — manage those through dedicated lifecycle
        operations.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCustomerRequest'
      responses:
        '200':
          description: Customer updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Full customer record
        '400':
          description: Missing name or malformed JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Service-level validation failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/customers/{id}/drop-ship-locations:
    post:
      tags:
        - Customers
      summary: Create a drop-ship location (Phase 3b)
      description: |
        Creates a third-party ship-to address parented to this customer. Not
        manufacturer-scoped — customer is the parent. State and Country are
        uppercased to match the web form.

        Returns 422 `invalid_drop_ship_location` with a semicolon-joined
        message when service validation rejects (missing name, address line,
        city, state, or zip).

        The web's `?modal=1` order-entry-modal flow returns 204 + an HX-Trigger
        event; the API is JSON-only and always returns the created entity.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - address_line_1
                - city
                - state
                - zip_code
              properties:
                name:
                  type: string
                attention:
                  type: string
                address_line_1:
                  type: string
                address_line_2:
                  type: string
                city:
                  type: string
                state:
                  type: string
                  description: Uppercased to match web form.
                zip_code:
                  type: string
                country:
                  type: string
                  default: US
                  description: Uppercased to match web form.
                phone:
                  type: string
                email:
                  type: string
                notes:
                  type: string
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      drop_ship_location:
                        type: object
        '400':
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Customer not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: Invalid drop-ship location
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/customers/{id}/drop-ship-locations/{loc_id}:
    patch:
      tags:
        - Customers
      summary: Update a drop-ship location (Phase 3b)
      description: |
        PATCH semantics: omitted fields preserve. Cross-customer attempts
        (a `loc_id` that belongs to a different customer than the URL `id`)
        return 404 to avoid leaking existence.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: loc_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                attention:
                  type: string
                address_line_1:
                  type: string
                address_line_2:
                  type: string
                city:
                  type: string
                state:
                  type: string
                zip_code:
                  type: string
                country:
                  type: string
                phone:
                  type: string
                email:
                  type: string
                notes:
                  type: string
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      drop_ship_location:
                        type: object
        '400':
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Invalid drop-ship location
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/customers/{id}/drop-ship-locations/{loc_id}/deactivate:
    post:
      tags:
        - Customers
      summary: Deactivate a drop-ship location (Phase 3b)
      description: >-
        Hides the location from order-entry pickers. Existing orders' FKs are
        preserved.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: loc_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Deactivated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      drop_ship_location:
                        type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/customers/{id}/drop-ship-locations/{loc_id}/activate:
    post:
      tags:
        - Customers
      summary: Reactivate a drop-ship location (Phase 3b)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: loc_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Reactivated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      drop_ship_location:
                        type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/customers/{id}/third-party-payers:
    post:
      tags:
        - Customers
      summary: Create a third-party freight payer (#1201)
      description: >
        Creates a freight bill-to party parented to this customer, used when an

        order's freight terms are "3RD PARTY". Not manufacturer-scoped —
        customer

        is the parent. State and Country are uppercased to match the web form.


        Returns 422 `invalid_third_party_payer` with a semicolon-joined message

        when service validation rejects (missing name, address line, city,
        state,

        zip, or carrier account #).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - address_line_1
                - city
                - state
                - zip_code
                - carrier_account
              properties:
                name:
                  type: string
                attention:
                  type: string
                address_line_1:
                  type: string
                address_line_2:
                  type: string
                city:
                  type: string
                state:
                  type: string
                  description: Uppercased to match web form.
                zip_code:
                  type: string
                country:
                  type: string
                  default: US
                  description: Uppercased to match web form.
                carrier_account:
                  type: string
                  description: UPS/FedEx account the freight is billed to.
                notes:
                  type: string
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      third_party_payer:
                        type: object
        '400':
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Customer not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: Invalid third-party payer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/customers/{id}/third-party-payers/{payer_id}:
    patch:
      tags:
        - Customers
      summary: Update a third-party payer (#1201)
      description: |
        PATCH semantics: omitted fields preserve. Cross-customer attempts
        (a `payer_id` that belongs to a different customer than the URL `id`)
        return 404 to avoid leaking existence.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: payer_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                attention:
                  type: string
                address_line_1:
                  type: string
                address_line_2:
                  type: string
                city:
                  type: string
                state:
                  type: string
                zip_code:
                  type: string
                country:
                  type: string
                carrier_account:
                  type: string
                notes:
                  type: string
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      third_party_payer:
                        type: object
        '400':
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Invalid third-party payer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/customers/{id}/third-party-payers/{payer_id}/deactivate:
    post:
      tags:
        - Customers
      summary: Deactivate a third-party payer (#1201)
      description: >-
        Hides the payer from order-entry pickers. Existing orders' FKs are
        preserved.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: payer_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Deactivated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      third_party_payer:
                        type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/customers/{id}/third-party-payers/{payer_id}/activate:
    post:
      tags:
        - Customers
      summary: Reactivate a third-party payer (#1201)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: payer_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Reactivated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      third_party_payer:
                        type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/locations:
    get:
      tags:
        - Locations
      summary: List warehouse locations (#1396)
      description: |
        Paginated list of warehouse locations with per-bin item counts.
        Requires `inventory.view`. Locations are warehouse-wide shared
        infrastructure; any actor with the permission may list them.
        `code` resolves a scanned barcode / location code exactly
        (trimmed, case-insensitive).
      parameters:
        - name: zone
          in: query
          schema:
            type: string
        - name: type
          in: query
          schema:
            type: string
          description: location_type filter (e.g. storage
          picking: null
          quarantine).: null
        - name: status
          in: query
          schema:
            type: string
            enum:
              - active
              - retired
        - name: code
          in: query
          schema:
            type: string
          description: Exact location-code lookup.
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: One page of locations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WarehouseLocation'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Locations
      summary: Create a warehouse location (Phase 3c)
      description: >
        Inserts a new location in the default warehouse. Requires
        `inventory.edit`.

        Locations are warehouse-wide — not manufacturer-scoped. Returns 422

        `code_taken` when the location_code collides with an existing row.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - location_code
                - location_type
              properties:
                location_code:
                  type: string
                location_type:
                  type: string
                  enum:
                    - storage
                    - staging
                    - receiving
                    - shipping
                    - quarantine
                zone:
                  type: string
                aisle:
                  type: string
                rack:
                  type: string
                shelf:
                  type: string
                bin:
                  type: string
                capacity:
                  type: integer
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      location:
                        $ref: '#/components/schemas/Location'
        '400':
          description: Missing required fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Duplicate code or other create failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/locations/{id}:
    get:
      tags:
        - Locations
      summary: Get a warehouse location (#1396)
      description: Requires `inventory.view`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The location.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WarehouseLocation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags:
        - Locations
      summary: Update a warehouse location (Phase 3c)
      description: PATCH semantics — omitted fields preserve.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                location_code:
                  type: string
                location_type:
                  type: string
                  enum:
                    - storage
                    - staging
                    - receiving
                    - shipping
                    - quarantine
                zone:
                  type: string
                aisle:
                  type: string
                rack:
                  type: string
                shelf:
                  type: string
                bin:
                  type: string
                capacity:
                  type: integer
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      location:
                        $ref: '#/components/schemas/Location'
        '400':
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Validation error (e.g., duplicate code, empty code)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/locations/{id}/stock:
    get:
      tags:
        - Locations
      summary: List the stock sitting in a bin (#1396)
      description: |
        Contents of one location. Requires `inventory.view`. Supply-partner-
        scoped actors see only their own items in the bin — other supply
        partners' stock is filtered out, not 403'd, because the bin itself is
        shared infrastructure.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The bin's contents.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      location_id:
                        type: string
                        format: uuid
                      items:
                        type: array
                        items:
                          $ref: '#/components/schemas/LocationStockItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/locations/{id}/retire:
    post:
      tags:
        - Locations
      summary: Retire a warehouse location (Phase 3c)
      description: >
        Soft-deletes the location. Refused if any stock remains (422

        `has_stock`) or the location is a quarantine bin (422
        `quarantine_refused`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Retired
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      location:
                        $ref: '#/components/schemas/Location'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Has stock or quarantine refused
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/locations/{id}/reactivate:
    post:
      tags:
        - Locations
      summary: Reactivate a retired location (Phase 3c)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Reactivated (or already active — no-op)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      location:
                        $ref: '#/components/schemas/Location'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/locations/{id}/relocate:
    post:
      tags:
        - Locations
      summary: Multi-item relocate from this location (Phase 3c)
      description: |
        Moves N items from this source bin to N destinations atomically. Each
        move targets a destination by code (resolved to id under the default
        warehouse). When `auto_create_dests` is omitted/false and any code
        doesn't resolve, the response is 422 `unknown_dests` with the codes
        echoed in `unknown_dests` — re-POST with `auto_create_dests=true` to
        create-on-scan and complete the move.

        Empty `moves`, or moves with `quantity <= 0` / empty `dest_code`, are
        silently dropped (matches the web's row-by-row form skip).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                auto_create_dests:
                  type: boolean
                  default: false
                moves:
                  type: array
                  items:
                    type: object
                    required:
                      - item_id
                      - dest_code
                      - quantity
                    properties:
                      item_id:
                        type: string
                        format: uuid
                      dest_code:
                        type: string
                      quantity:
                        type: integer
                        minimum: 1
      responses:
        '200':
          description: Relocate succeeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      result:
                        type: object
                        properties:
                          ItemsMoved:
                            type: integer
                          QuantityMoved:
                            type: integer
                          BinsCreated:
                            type: array
                            items:
                              type: string
                          UnknownDests:
                            type: array
                            items:
                              type: string
                          UnknownItems:
                            type: array
                            items:
                              type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Unknown destinations (without auto_create_dests). The body shape
            on this case is `{ "error": { "code": "unknown_dests", ... },
            "unknown_dests": ["...", "..."] }` so the caller can dedupe.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/locations/{id}/move-all:
    post:
      tags:
        - Locations
      summary: Empty a location into another (Phase 3c)
      description: |
        Moves everything in the source bin to `dest_code`. When the destination
        does not exist and `auto_create=false`, response is 422 `unknown_dest`
        with the code echoed in `dest_code`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - dest_code
              properties:
                dest_code:
                  type: string
                auto_create:
                  type: boolean
                  default: false
      responses:
        '200':
          description: Move-all succeeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      result:
                        type: object
                        properties:
                          ItemsMoved:
                            type: integer
                          QuantityMoved:
                            type: integer
                      dest:
                        $ref: '#/components/schemas/Location'
        '400':
          description: Missing dest_code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Unknown dest, same location, or inactive destination
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/locations/bulk:
    post:
      tags:
        - Locations
      summary: Bulk-create locations from a range spec (Phase 3d)
      description: |
        Generates the cartesian product of the supplied axes, applies the
        `code_template`, and inserts each row in one transaction. Existing
        codes are skipped (counted in `result.skipped_existing`); new rows
        are created. Hard cap: 5,000 rows per call → 422 `too_large` if the
        product exceeds it.

        The web's HTML preview step is intentionally not exposed — n8n / scripts
        know what they're submitting (same precedent as receiving import).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - location_type
              properties:
                location_type:
                  type: string
                  enum:
                    - storage
                    - staging
                    - receiving
                    - shipping
                    - quarantine
                code_template:
                  type: string
                  description: >
                    Token template with `{zone}`, `{aisle}`, `{rack}`,
                    `{shelf}`,

                    `{bin}` placeholders. Empty defaults to
                    "{zone}-{aisle}-{shelf}-{bin}".
                capacity:
                  type: integer
                ranges:
                  type: object
                  properties:
                    zone:
                      type: object
                      properties:
                        values:
                          type: array
                          items:
                            type: string
                    aisle:
                      type: object
                      properties:
                        from:
                          type: integer
                        to:
                          type: integer
                        pad:
                          type: integer
                          description: Zero-padding width (e.g. pad=2 → "01"-"20")
                    rack:
                      type: object
                      properties:
                        values:
                          type: array
                          items:
                            type: string
                    shelf:
                      type: object
                      properties:
                        from:
                          type: integer
                        to:
                          type: integer
                        pad:
                          type: integer
                    bin:
                      type: object
                      properties:
                        from:
                          type: integer
                        to:
                          type: integer
                        pad:
                          type: integer
      responses:
        '200':
          description: Bulk create completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      result:
                        type: object
                        properties:
                          Created:
                            type: integer
                          SkippedExisting:
                            type: integer
                          Errors:
                            type: array
                            items:
                              type: string
        '400':
          description: Missing location_type or invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: 5,000-row cap exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/locations/import:
    post:
      tags:
        - Locations
      summary: Import locations from CSV (Phase 3d)
      description: |
        Imports a CSV file of locations, creating new rows and updating
        existing ones (matched by location_code). Per-row errors are surfaced
        in `result.row_errors` while other rows still commit (matches the
        web's tolerant import semantics).

        Two body shapes are accepted:
        - `multipart/form-data` with a `csv_file` field
        - `application/json` with `{ "csv_text": "..." }`

        Both share the JSON group's 5 MB body cap. The web's HTML preview
        step is not exposed — preview-then-confirm is a UI affordance.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - csv_file
              properties:
                csv_file:
                  type: string
                  format: binary
          application/json:
            schema:
              type: object
              required:
                - csv_text
              properties:
                csv_text:
                  type: string
      responses:
        '200':
          description: Import completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      result:
                        type: object
                        properties:
                          Created:
                            type: integer
                          Updated:
                            type: integer
                          RowErrors:
                            type: integer
                          MissingFromCSV:
                            type: array
                            items:
                              type: string
        '400':
          description: Missing/invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: CSV parsing or schema validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/api-keys:
    get:
      tags:
        - API Keys
      summary: List all API keys
      description: Requires admin.api_keys permission.
      responses:
        '200':
          description: List of API keys (no plaintext keys)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - API Keys
      summary: Create a new API key
      description: >
        Generates a new API key. The plaintext key is returned once in the
        response.

        Validates that requested scopes are within both the caller's permissions

        and the target user's current role permissions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAPIKeyRequest'
      responses:
        '201':
          description: >-
            Key created. The `key` field contains the plaintext key (shown
            once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      key:
                        type: string
                        description: Plaintext key (shown once)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Invalid scopes or target user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/api-keys/{id}/revoke:
    post:
      tags:
        - API Keys
      summary: Revoke an API key
      description: Soft-deletes the key (sets is_active = false). Cannot be undone.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Key revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: revoked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/manufacturers:
    get:
      tags:
        - Supply Partners
      summary: List supply partners
      description: >
        Returns all active supply partners for dropdown/lookup use. Not
        paginated

        (a warehouse has at most a few dozen supply partners).
        Supply-partner-scoped users see only

        their own supply partner. Requires manufacturers.view permission.
      responses:
        '200':
          description: Array of supply partners
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      description: Supply partner record
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - Supply Partners
      summary: Create a supply partner
      description: |
        Creates a new supply partner record. Requires manufacturers.edit
        permission AND an unscoped key (manufacturer-scoped users cannot
        mint additional supply partners). `commission_rate` is a decimal
        fraction (0.10 = 10%); the web form takes a percentage but the API
        takes the storage form so n8n round-trips don't have to multiply.
        `type_code` defaults to `consigned` when omitted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateManufacturerRequest'
      responses:
        '201':
          description: Supply partner created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        '400':
          description: Missing code/name or malformed JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Duplicate code or service-level validation failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/manufacturers/{id}:
    patch:
      tags:
        - Supply Partners
      summary: Update a supply partner
      description: |
        Updates an existing supply partner. Requires manufacturers.edit
        permission. Manufacturer-scoped keys can only update their own supply
        partner. Omitting `type_code` or `statement_cadence` preserves the
        existing value (matches the web "mobile form omits the field" fallback).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateManufacturerRequest'
      responses:
        '200':
          description: Supply partner updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        '400':
          description: Missing name or malformed JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Service-level validation failure (invalid statement_cadence, etc.).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/manufacturers/{id}/item-prices:
    get:
      tags:
        - Item Prices
      summary: List supply-partner item prices
      description: |
        Lists prices for the given supply partner. Optional `item_id` filter
        narrows to a single item; optional `active_at` (RFC3339) returns only
        rows whose effective window contains the timestamp. Without
        `active_at`, the full history (all open + closed rows) is returned.
        Requires `manufacturers.view`. Manufacturer-scoped keys can only
        target their own supply partner.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: item_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - name: active_at
          in: query
          required: false
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Array of price rows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ItemPrice'
        '400':
          description: Malformed `active_at` query parameter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/manufacturers/{id}/item-prices/{price_id}:
    get:
      tags:
        - Item Prices
      summary: Get a single price row
      description: |
        Fetches a specific price row by ID. Requires `manufacturers.view`.
        Returns 404 when the row doesn't exist or belongs to a different
        supply partner (the 404-on-cross-tenant pattern matches the rest of
        the API).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: price_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The price row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ItemPrice'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Item Prices
      summary: End-date a price row
      description: |
        Sets `effective_to = now()` on a still-open price row. Never
        hard-deletes — historical rows are part of the audit surface.
        Requires `manufacturers.edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: price_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Row end-dated.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: The row is already end-dated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
    patch:
      tags:
        - Item Prices
      summary: Update notes on a price row
      description: |
        Updates ONLY the `notes` field on a price row. The
        `unit_price`, `effective_from`, and `effective_to` columns
        remain immutable through this endpoint — to change those,
        use `DELETE` (end-date) and `PUT` (insert new). Issue #954.
        Requires `manufacturers.edit`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: price_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateItemPriceNotesRequest'
      responses:
        '200':
          description: Updated price row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ItemPrice'
        '400':
          description: Malformed JSON body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/manufacturers/{id}/item-prices/{price_id}/cancel-scheduled:
    post:
      tags:
        - Item Prices
      summary: Cancel a scheduled (future-dated) price
      description: |
        Hard-deletes a future-dated (still-open) price row AND
        restores the prior open row's `effective_to` to NULL in one
        transaction. The restore is required because `PUT` end-dates
        the prior row at the new row's `effective_from`; cancelling
        the new row alone would leave the item with no active price
        when the cancelled date passes. Issue #954. Requires
        `manufacturers.edit`. Distinct from `DELETE` to preserve
        existing end-date semantics for already-shipped clients.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: price_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Row cancelled (and prior row re-opened if any).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            The row has already activated (`effective_from <= now()`).
            Use `DELETE` to end-date it instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/manufacturers/{id}/items/{item_id}/price:
    put:
      tags:
        - Item Prices
      summary: Set the active price for an item
      description: |
        Idempotent upsert keyed by item. End-dates any prior open row at the
        new row's `effective_from` and inserts a new row. Pass `effective_from`
        to set a future- or past-effective price; omit to use server now.
        Requires `manufacturers.edit`. The trigger rejects mismatched
        `manufacturer_id`/`item_id` pairs as 404 ("item not in your scope").
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: item_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertItemPriceRequest'
      responses:
        '200':
          description: New price row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ItemPrice'
        '400':
          description: Malformed JSON body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Manufacturer scope mismatch, OR the item belongs to a different
            supply partner.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '409':
          description: Concurrent upsert race — retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: |
            Negative `unit_price`, OR `effective_from` is at-or-before the
            prior open row's `effective_from`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/documents/{id}/download:
    get:
      tags:
        - Documents
      summary: Download a document
      description: |
        Streams the document bytes. Requires documents.view permission AND
        the parent entity's *.view permission (e.g., orders.view to download
        an order attachment). Manufacturer-scoped keys can only download
        documents whose parent entity belongs to their supply partner.
        Always returns Content-Disposition: attachment — the inline preview
        behavior is HTML-only.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Document file content.
          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/v1/documents/{id}:
    delete:
      tags:
        - Documents
      summary: Delete a document
      description: |
        Soft-deletes a document. Requires documents.delete permission AND
        the parent entity's *.view permission. Manufacturer-scoped keys can
        only delete documents whose parent entity belongs to their supply
        partner.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Document deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/documents/{entityType}/{entityID}:
    post:
      tags:
        - Documents
      summary: Upload a document
      description: |
        Uploads a document (PDF, image, Excel, CSV, Word, TXT) as an
        attachment to a parent entity. Accepts multipart/form-data with a
        `file` part. Supports 50 MB body (the JSON /api/v1/ cap is 5 MB —
        this endpoint has a dedicated larger cap). Requires
        documents.upload permission.
      parameters:
        - name: entityType
          in: path
          required: true
          schema:
            type: string
            enum:
              - order
              - item
              - manufacturer
              - customer
              - receiving_line
              - mispick_exception
              - quality_exception
              - short_pick_exception
        - name: entityID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - file
              properties:
                file:
                  type: string
                  format: binary
                description:
                  type: string
                tags:
                  type: array
                  items:
                    type: string
                  description: >
                    Optional labels stored on the document. Repeat the field to

                    send multiple. The value `manufacturer-packing-list` marks
                    an

                    order's document as the supply partner's own packing list,

                    which is then auto-surfaced for printing on the warehouse

                    pack screen (#1203).
      responses:
        '201':
          description: Document uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Document record
        '400':
          description: >-
            Invalid entityType, malformed UUID, missing file, or malformed
            multipart body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          description: File exceeds the 50 MB documents cap
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: Disallowed file type or content/extension mismatch
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/events:
    get:
      tags:
        - Events
      summary: List unacked outbound events
      description: |
        Cursor-style poll for the outbound event stream. Used by n8n and any
        other pull-based integration consumer. Returns unacked events ordered
        by (occurred_at ASC, id ASC) for deterministic cursor advance.
        Requires events.read permission.
      parameters:
        - name: limit
          in: query
          description: Page size; defaults to 100, clamped to 500.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
        - name: since
          in: query
          description: RFC3339 occurred_at floor (inclusive).
          schema:
            type: string
            format: date-time
        - name: types
          in: query
          description: CSV of event_type values to include.
          schema:
            type: string
            example: order.shipped.v1,order.released.v1
      responses:
        '200':
          description: Batch of unacked events (possibly empty)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/StoredEvent'
        '400':
          description: Invalid limit or since format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/events/{id}/ack:
    post:
      tags:
        - Events
      summary: Acknowledge an event
      description: |
        Marks the given event as delivered. Idempotent — acking a
        previously-acked event succeeds without changing the original
        published_at timestamp. Requires events.read permission (acking
        requires the same scope as reading).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Event acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      acknowledged:
                        type: boolean
                        example: true
        '400':
          description: id is not a UUID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/shipments/events:
    get:
      tags:
        - Shipping
      summary: List shipment-completion events (tenant-scoped, idempotent)
      description: |
        Cursor-based pull surface for downstream commission, ICM, and
        sales-activity consumers (Repfabric n8n connector and similar).
        Distinct from `/api/v1/events`:

        * **Tenant-scoped.** Manufacturer-scoped API keys see only their
          own supply partner's shipments. Admin keys see all tenants and
          may filter via `?manufacturer_id=`. A scoped key passing
          `?manufacturer_id=<other>` is rejected with 403 — no silent
          re-scoping.

        * **Idempotent.** No ack mutation; re-issuing the same `?since=`
          returns the same window. Consumers dedupe by `event_id`.

        * **Filtered to shipment-bearing event types.** Today
          `order.shipped.v1` only; future shipment-bearing types are added
          via the events.ShipmentEventTypes allowlist.

        Order: ascending `(occurred_at, id)`. The `next_cursor` field is
        non-null whenever the result page filled the limit; pass it back
        as `?since=<cursor>` to fetch the next page.

        Note: `aggregate_id` IS the shipment identifier in CT's data model
        (a shipment is an order in terminal `shipped` status). There is no
        separate shipments aggregate today.

        Requires `orders.view` permission.
      parameters:
        - name: limit
          in: query
          description: Page size; defaults to 100, clamped to 500.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
        - name: since
          in: query
          description: |
            Either an RFC3339 occurred_at floor (inclusive) or an opaque
            continuation cursor returned by a prior call's `next_cursor`.
            The two forms are auto-detected; consumers can ignore the
            distinction and treat `next_cursor` as a string to round-trip.
          schema:
            type: string
        - name: manufacturer_id
          in: query
          description: |
            Restrict results to one supply partner. Admin keys may scope
            this way; manufacturer-scoped keys may pass it but only with
            their own scope (mismatched scope returns 403).
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Page of shipment events (possibly empty).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShipmentEventListResponse'
        '400':
          description: Invalid limit, since, or manufacturer_id format.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/webhooks:
    post:
      tags:
        - Webhooks
      summary: Register a webhook subscription
      description: |
        Creates a new subscription. The plaintext `signing_secret` is
        returned on the response — exactly once, here. Store it on the
        receiver immediately; ConsignTrak does not retain a way to
        re-issue it for the same secret (use rotate-secret to mint a new
        one).

        Tenant scope: manufacturer-scoped API keys are forced to their
        own scope. An explicit `manufacturer_id` matching the key's scope
        is allowed; a different value returns 403.

        Requires `orders.view` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookSubscriptionCreate'
      responses:
        '201':
          description: >-
            Subscription created. signing_secret is in the body — persist it
            now.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookSubscriptionWithSecret'
        '400':
          description: >-
            Invalid body (missing url / event_types, bad scheme, bad
            max_attempts).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            Insufficient permissions OR scoped key passed a different
            manufacturer_id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: |
            An event_types value is not a known event type (code
            `unknown_event_type`). The message names the offending value
            and lists the valid catalog — also enumerable via
            `GET /api/v1/webhooks/event-types`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
    get:
      tags:
        - Webhooks
      summary: List webhook subscriptions
      description: |
        Returns subscriptions visible to the caller. Manufacturer-scoped
        keys see only their own tenant. Admin keys see all subscriptions
        including admin-wide ones (NULL manufacturer_id). Inactive
        subscriptions are excluded by default; pass
        `?include_inactive=true` to include them.

        signing_secret is NEVER included on this surface — only on
        POST and rotate-secret.

        Requires `orders.view` permission.
      parameters:
        - name: include_inactive
          in: query
          description: Set to `true` to include deactivated subscriptions.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: List of subscriptions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookSubscription'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/webhooks/event-types:
    get:
      tags:
        - Webhooks
      summary: List subscribable event types
      description: |
        Discovery endpoint: returns the catalog of event types a webhook
        subscription may include in `event_types`, sorted lexicographically.
        Integrators (and n8n workflows) should enumerate types from here
        rather than transcribing them from docs — values outside this
        catalog are rejected with 422 on subscription create.

        Requires `orders.view` permission.
      responses:
        '200':
          description: Sorted list of valid event types.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: string
                      example: order.shipped.v1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/webhooks/{id}:
    get:
      tags:
        - Webhooks
      summary: Fetch one webhook subscription
      description: >-
        Requires `orders.view`. Manufacturer-scoped keys get 404 (not 403) on
        cross-tenant ids — existence is not leaked.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Subscription.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookSubscription'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Webhooks
      summary: Deactivate a webhook subscription
      description: |
        Soft-deletes the subscription (sets `is_active=false`). Past
        deliveries' FK references stay valid and the audit trail is
        intact. Hard delete is intentionally not exposed.

        Requires `orders.view`. Manufacturer-scoped keys get 404 (not
        403) on cross-tenant ids.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deactivated.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/webhooks/{id}/rotate-secret:
    post:
      tags:
        - Webhooks
      summary: Rotate the signing secret for a subscription
      description: |
        Generates a fresh 32-byte signing secret, replacing the previous
        one. Increments `signing_secret_version`. Returns the plaintext
        once — receivers must update their stored secret BEFORE the next
        delivery or signature verification will fail.

        Today the dispatcher always signs with the latest secret; future
        schemes may support overlapping windows keyed by
        `signing_secret_version`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: New signing secret. signing_secret is in the body — persist it now.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookSubscriptionWithSecret'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/contacts/{entityType}/{entityID}:
    get:
      tags:
        - Contacts
      summary: List active contacts for an entity
      description: |
        Returns the active contacts associated with the given entity,
        each bundled with its association id + role. Inactive contacts
        are filtered out. Order: primary first, then last name.
        Requires the parent entity's `*.view` permission.
      parameters:
        - name: entityType
          in: path
          required: true
          schema:
            type: string
            enum:
              - manufacturer
              - customer
        - name: entityID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: List of contacts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ContactWithRole'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '400':
          description: Invalid entity type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Contacts
      summary: Create a contact and link it to an entity
      description: |
        Creates a new contact and an association in one transaction.
        Requires the parent entity's `*.edit` permission.
      parameters:
        - name: entityType
          in: path
          required: true
          schema:
            type: string
            enum:
              - manufacturer
              - customer
        - name: entityID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactCreateRequest'
      responses:
        '201':
          description: Contact created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactWithRole'
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/contacts/{id}:
    get:
      tags:
        - Contacts
      summary: Get a single contact by id
      description: |
        Returns the contact (active or inactive). Authorization: caller
        must have view perm on at least one entity the contact is
        associated with.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The contact
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/contacts/{id}/update:
    post:
      tags:
        - Contacts
      summary: Update a contact's writable fields
      description: |
        Mutates name, title, email, phone, mobile, is_primary, notes.
        Status flips and per-association role changes go through their
        own endpoints. Authorization: caller must have edit perm on at
        least one entity the contact is linked to.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactUpdateRequest'
      responses:
        '200':
          description: Updated contact
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/contacts/{id}/deactivate:
    post:
      tags:
        - Contacts
      summary: Soft-delete a contact (status = inactive)
      description: |
        Sets status to inactive. Associations stay in the database but
        the contact disappears from list endpoints and LookupByRole.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Contact deactivated
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/contacts/{id}/reactivate:
    post:
      tags:
        - Contacts
      summary: Restore a previously deactivated contact
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Contact reactivated
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/associations/{id}/disassociate:
    post:
      tags:
        - Contacts
      summary: Drop a single contact-entity association
      description: |
        Removes the link between a contact and one entity. The contact
        and its other associations survive. Authorization: caller must
        have edit perm on the entity the association points to.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Association removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/receiving:
    get:
      tags:
        - Receiving
      summary: List receiving batches (#1396)
      description: |
        Paginated batch list, newest first. Requires `receiving.view`.
        Supply-partner-scoped actors are forced to their own supply partner
        regardless of the `manufacturer` param.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - draft
              - verified
              - posted
              - voided
          description: Repeatable.
        - name: manufacturer
          in: query
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          schema:
            type: string
            format: date
          description: received_date lower bound (YYYY-MM-DD).
        - name: to
          in: query
          schema:
            type: string
            format: date
          description: received_date upper bound (inclusive).
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: One page of receiving batches.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReceivingBatch'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Receiving
      summary: Create a draft receiving batch
      description: |
        Creates a new draft receiving batch for a supply partner. Lines are
        added separately via `POST /api/v1/receiving/{id}/lines`. Requires
        receiving.create permission. Manufacturer-scoped keys can only
        create batches for their own supply partner.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - manufacturer_id
              properties:
                manufacturer_id:
                  type: string
                  format: uuid
                received_date:
                  type: string
                  format: date
                  description: |
                    YYYY-MM-DD. Omit to default to today in the server's local
                    timezone (#970). Foreign callers that don't share the
                    server's timezone should send this explicitly to avoid
                    off-by-one-day surprises near midnight.
                notes:
                  type: string
                  nullable: true
      responses:
        '201':
          description: Batch created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/receiving/import:
    post:
      tags:
        - Receiving
      summary: Bulk-import a receiving batch from a parsed payload
      description: |
        JSON twin of the spreadsheet-import flow. Caller hands us already-
        resolved item_ids and qtys; we create a draft batch and write each
        row with import semantics (qty_received == qty_expected, so the
        variance display lights up if a line is later edited). Lines that
        fail validation are reported in `skipped` with their array index +
        reason — caller can DELETE the partial batch and retry. Requires
        inventory.import permission. Manufacturer-scoped keys can only
        import for their own supply partner.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - manufacturer_id
                - lines
              properties:
                manufacturer_id:
                  type: string
                  format: uuid
                received_date:
                  type: string
                  format: date
                  description: |
                    YYYY-MM-DD. Omit to default to today in the server's local
                    timezone (#970). Foreign callers that don't share the
                    server's timezone should send this explicitly to avoid
                    off-by-one-day surprises near midnight.
                notes:
                  type: string
                  nullable: true
                lines:
                  type: array
                  items:
                    type: object
                    required:
                      - item_id
                      - qty_received
                    properties:
                      item_id:
                        type: string
                        format: uuid
                      qty_received:
                        type: integer
                        minimum: 1
                      reference_number:
                        type: string
                      reference_type:
                        type: string
                      new_unit_cost:
                        type: number
                        format: float
                        nullable: true
                      new_location_id:
                        type: string
                        format: uuid
                        nullable: true
      responses:
        '201':
          description: Batch created (possibly with skipped lines).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
                      added:
                        type: integer
                      skipped:
                        type: array
                        items:
                          type: object
                          properties:
                            index:
                              type: integer
                            reason:
                              type: string
                            item_id:
                              type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/manufacturers/{id}/receiving-import-profile:
    parameters:
      - name: id
        in: path
        required: true
        description: Supply partner (manufacturer) id.
        schema:
          type: string
          format: uuid
    get:
      tags:
        - Receiving
      summary: Get a supply partner's saved import mapping profile
      description: |
        The saved column mapping the receiving-import wizard applies to this
        supply partner's manifest uploads: which worksheet and header row
        their files use, and which header text carries each canonical
        receiving field. Requires inventory.import permission.
        Manufacturer-scoped keys can only read their own supply partner's
        profile (cross-partner requests 404).
      responses:
        '200':
          description: The saved mapping profile.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ReceivingImportProfile'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - Receiving
      summary: Create or replace a supply partner's import mapping profile
      description: |
        Full replace (upsert) — the same last-write-wins semantics as saving
        from the mapping wizard. `column_map` keys are the canonical fields
        (`item`, `qty`, `ref`, `cost`, `uom`); `item` and `qty` are required;
        values are the source file's header text. Requires inventory.import
        permission and the same manufacturer scoping as GET.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - header_row
                - column_map
              properties:
                sheet_name:
                  type: string
                  nullable: true
                  description: >-
                    Worksheet name; omit/null for the first sheet (or CSV
                    files).
                header_row:
                  type: integer
                  minimum: 1
                column_map:
                  type: object
                  additionalProperties:
                    type: string
                  description: Canonical field → source header text.
      responses:
        '200':
          description: The saved mapping profile.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ReceivingImportProfile'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: >-
            Validation failed (bad header_row, unknown field, blank header text,
            missing item/qty).
    delete:
      tags:
        - Receiving
      summary: Delete a supply partner's import mapping profile
      description: |
        Removes the saved mapping; the next manifest upload for this supply
        partner drops back into the mapping wizard. Requires
        inventory.import permission and the same manufacturer scoping as GET.
      responses:
        '204':
          description: Profile deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/receiving/{id}:
    get:
      tags:
        - Receiving
      summary: Get a receiving batch with its lines (#1396)
      description: |
        Requires `receiving.view`. Supply-partner-scoped actors get 404 for
        another supply partner's batch.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The batch and its lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags:
        - Receiving
      summary: Delete a draft receiving batch
      description: |
        Deletes a draft batch and all its lines. Requires receiving.create
        permission. Returns 422 invalid_status if the batch is not draft.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Batch deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch is not in draft status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/receiving/{id}/lines:
    post:
      tags:
        - Receiving
      summary: Add a line to a draft batch
      description: |
        Appends a line. Requires receiving.create permission and a draft
        batch. The item must belong to the same supply partner as the
        batch (422 manufacturer_mismatch otherwise).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - item_id
                - qty_received
              properties:
                item_id:
                  type: string
                  format: uuid
                qty_received:
                  type: integer
                  minimum: 1
                reference_number:
                  type: string
                reference_type:
                  type: string
                new_unit_cost:
                  type: number
                  format: float
                  nullable: true
                new_location_id:
                  type: string
                  format: uuid
                  nullable: true
      responses:
        '201':
          description: Line added.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      line:
                        $ref: '#/components/schemas/ReceivingLine'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: >-
            Batch not draft, item not found, or item belongs to a different
            supply partner.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/receiving/{id}/lines/{lineID}:
    patch:
      tags:
        - Receiving
      summary: Update a draft line
      description: |
        Edits qty_received, reference_number, new_unit_cost, and/or
        new_location_id. Requires receiving.create permission and a draft
        batch. Returns the reloaded `{batch, lines}` so callers see the
        post-update view.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: lineID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - qty_received
              properties:
                qty_received:
                  type: integer
                  minimum: 1
                reference_number:
                  type: string
                new_unit_cost:
                  type: number
                  format: float
                  nullable: true
                new_location_id:
                  type: string
                  format: uuid
                  nullable: true
      responses:
        '200':
          description: Line updated; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch is not in draft status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
    delete:
      tags:
        - Receiving
      summary: Remove a line from a draft batch
      description: Requires receiving.create permission and a draft batch.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: lineID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Line removed.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/receiving/{id}/lines/{lineID}/inspect:
    post:
      tags:
        - Receiving
      summary: Record QC inspection results for a line
      description: |
        Records pass/fail counts. Requires receiving.qc permission and a
        verified batch. `qty_passed + qty_failed` must equal the line's
        `qty_received` (400 bad_request on mismatch). Returns the reloaded
        `{batch, lines}` so callers see the new inspection state.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: lineID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - qty_passed
                - qty_failed
              properties:
                qty_passed:
                  type: integer
                  minimum: 0
                qty_failed:
                  type: integer
                  minimum: 0
                inspection_notes:
                  type: string
      responses:
        '200':
          description: Inspection recorded; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch is not verified.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/receiving/{id}/lines/{lineID}/pass-all:
    post:
      tags:
        - Receiving
      summary: Pass-all-qty inspection (toggle)
      description: |
        Marks a line fully passed (qty_passed = qty_received). If the line
        is already fully passed, the same call reverts it to pending — same
        toggle semantics as the mobile swipe-right gesture. Requires
        receiving.qc permission and a verified batch.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: lineID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Inspection toggled; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch is not verified.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/receiving/{id}/lines/{lineID}/waive:
    post:
      tags:
        - Receiving
      summary: Waive QC inspection for a line
      description: |
        Marks a line's inspection as waived, clearing any prior inspection
        data. Requires receiving.qc permission and a verified batch.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: lineID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Inspection waived; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/receiving/{id}/verify:
    post:
      tags:
        - Receiving
      summary: Mark a draft batch as verified (ready to inspect / post)
      description: |
        Transitions draft → verified. Empty batches are refused (422
        batch_empty). Requires receiving.inspect permission.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Batch verified; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch is not draft, or batch has no lines.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/receiving/{id}/unverify:
    post:
      tags:
        - Receiving
      summary: Revert a verified batch to draft
      description: |
        Transitions verified → draft. Clears all line inspection data so
        QC can be redone after edits. Requires receiving.inspect permission.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Batch reverted to draft; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch is not in verified status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/receiving/{id}/post:
    post:
      tags:
        - Receiving
      summary: Post a verified batch to inventory
      description: |
        High-stakes audit-trail event: in a single transaction, increments
        on-hand quantities, writes inventory_movements + item_history,
        upserts item_locations, and stamps received/last-movement dates.
        QC-failed quantities go to the quarantine location instead of
        available stock. Refuses any line still in `pending` inspection
        status (422 pending_qc) — inspect or waive each line first.
        Requires receiving.post permission.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Batch posted; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch not verified, or one or more lines still pending QC.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/receiving/{id}/void:
    post:
      tags:
        - Receiving
      summary: Void a posted batch
      description: |
        Reverses the inventory effect of a posted batch by writing
        compensating movement / history rows. Cost calculations are NOT
        reversed (subsequent transactions may have already shifted
        average_cost in ways that cannot be cleanly undone). Requires
        receiving.post permission. `reason` is required (non-empty).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - reason
              properties:
                reason:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Batch voided; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch is not in posted status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/receiving/{id}/set-location:
    post:
      tags:
        - Receiving
      summary: Set the same location on every line of a draft batch
      description: |
        Bulk-set the new_location_id on all lines of a draft batch.
        Requires receiving.create permission and an active warehouse
        location.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - location_id
              properties:
                location_id:
                  type: string
                  format: uuid
      responses:
        '200':
          description: Location set; returns reloaded batch + lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/ReceivingBatch'
                      lines:
                        type: array
                        nullable: true
                        description: null, not [], when the batch has no lines.
                        items:
                          $ref: '#/components/schemas/ReceivingLine'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch is not in draft status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/cycle-counts:
    get:
      tags:
        - Cycle Counts
      summary: List cycle-count batches (#1396)
      description: |
        Paginated batch list, newest first. Requires `cycle_counts.view`.
        Cycle counts are warehouse-wide — supply-partner-scoped actors are
        refused with 403, matching the write endpoints.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - draft
              - in_progress
              - completed
              - cancelled
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: One page of cycle-count batches.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CycleCountBatch'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Cycle Counts
      summary: Generate a cycle count batch
      description: |
        Creates a new draft batch with a random sample of items.
        Manufacturer-scoped keys are refused (sample crosses supply-partner
        lines). Requires cycle_counts.count permission.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                num_items:
                  type: integer
                  minimum: 1
                  default: 10
      responses:
        '201':
          description: Batch generated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/cycle-counts/{id}:
    get:
      tags:
        - Cycle Counts
      summary: Get a cycle-count batch with its lines (#1396)
      description: |
        Batch header plus every line with counted quantities and variances.
        Requires `cycle_counts.view`; supply-partner-scoped actors are refused
        with 403.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The batch and its lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch:
                        $ref: '#/components/schemas/CycleCountBatch'
                      lines:
                        type: array
                        items:
                          $ref: '#/components/schemas/CycleCountLine'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/cycle-counts/{id}/start:
    post:
      tags:
        - Cycle Counts
      summary: Mark a cycle count batch as in_progress
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Batch started.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Batch is not in draft status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/cycle-counts/{id}/count:
    post:
      tags:
        - Cycle Counts
      summary: Record a counted quantity for a line
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - line_id
                - counted_qty
              properties:
                line_id:
                  type: string
                  format: uuid
                counted_qty:
                  type: integer
                  minimum: 0
      responses:
        '204':
          description: Count recorded.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Line is not in pending status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/cycle-counts/{id}/lines/{lineID}/approve:
    post:
      tags:
        - Cycle Counts
      summary: Approve a counted line and adjust inventory
      description: Requires cycle_counts.approve permission.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: lineID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Variance approved; inventory adjusted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Line could not be approved (e.g. not in counted status).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/cycle-counts/{id}/complete:
    post:
      tags:
        - Cycle Counts
      summary: Mark a cycle count batch as completed
      description: Requires cycle_counts.approve permission.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Batch completed.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Batch could not be completed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/inventory/adjustments:
    get:
      tags:
        - Inventory
      summary: List inventory adjustments
      description: |
        The review ledger behind /inventory/adjustments (#1334): every
        inventory movement of type `adjustment` (manual) or
        `cycle_count_correction`, newest first, paginated. Requires
        inventory.view permission. Supply-partner-scoped keys only see their
        own supply partner's adjustments regardless of the `manufacturer`
        param. Unlike the web page, no default date window is applied — an
        unbounded call lists everything.
      parameters:
        - name: from
          in: query
          schema:
            type: string
            format: date
          description: Inclusive lower bound on performed_at (yyyy-mm-dd).
        - name: to
          in: query
          schema:
            type: string
            format: date
          description: >-
            Inclusive upper bound on performed_at (yyyy-mm-dd; widened to end of
            day).
        - name: manufacturer
          in: query
          schema:
            type: string
            format: uuid
          description: >-
            Filter by supply partner ID (ignored for supply-partner-scoped
            keys).
        - name: q
          in: query
          schema:
            type: string
          description: >-
            Item search — normalized part-number prefix or display-form
            substring.
        - name: performed_by
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by the user who recorded the movement.
        - name: reason
          in: query
          schema:
            type: string
          description: >-
            Exact match on the stored reason token (e.g. `damaged`,
            `found_stock`).
        - name: type
          in: query
          schema:
            type: string
            enum:
              - adjustment
              - cycle_count_correction
          description: Movement type; omit for both.
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        '200':
          description: List of adjustment movements with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/AdjustmentMovement'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Inventory
      summary: Adjust an item's qty_on_hand
      description: |
        Atomically increments item qty_on_hand by the (signed) quantity,
        keeps item_locations totals in sync, writes a chained
        inventory_movement and an item_history row. Reason is required and
        appears on the movement record. Requires inventory.adjust permission.
        Manufacturer-scoped keys can only adjust items that belong to their
        own supply partner.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - item_id
                - quantity
                - reason
              properties:
                item_id:
                  type: string
                  format: uuid
                quantity:
                  type: integer
                  description: >-
                    Signed delta (positive adds stock, negative removes). Must
                    be non-zero and within ±100,000,000.
                  minimum: -100000000
                  maximum: 100000000
                reason:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Adjustment applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      item_id:
                        type: string
                        format: uuid
                      manufacturer_id:
                        type: string
                        format: uuid
                      new_balance:
                        type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/inventory/movements:
    get:
      tags:
        - Inventory
      summary: List inventory movements (#1396)
      description: |
        The full inventory-movement ledger — every movement type, not just the
        adjustment pair served by /inventory/adjustments. Newest first,
        paginated, no default date window. Requires `inventory.view`.
        Supply-partner-scoped actors are forced to their own supply partner.
        `type` is repeatable; an unknown value is a 400 (never silently
        dropped — on a full ledger that would return everything).
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum:
              - receipt
              - shipment
              - adjustment
              - transfer
              - return
              - cycle_count_correction
          description: Repeatable movement-type filter; omit for all types.
        - name: manufacturer
          in: query
          schema:
            type: string
            format: uuid
        - name: q
          in: query
          schema:
            type: string
          description: Item number prefix / substring match.
        - name: performed_by
          in: query
          schema:
            type: string
            format: uuid
        - name: reason
          in: query
          schema:
            type: string
        - name: from
          in: query
          schema:
            type: string
            format: date
          description: performed_at lower bound (YYYY-MM-DD).
        - name: to
          in: query
          schema:
            type: string
            format: date
          description: performed_at upper bound (inclusive).
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: One page of the movement ledger.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/InventoryMovement'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/exceptions:
    get:
      tags:
        - Exceptions
      summary: List pick exceptions (#1416)
      description: |
        Short-pick, mispick, and quality-flag exceptions in one list, newest
        first. Requires `orders.view`. Supply-partner-scoped actors are forced
        to their own supply partner (an explicit `manufacturer` is ignored).

        No `state` means every state — unlike the office queue, which defaults
        to open. Paging is applied in memory over the merged result (the three
        kinds live in separate tables and are unioned after the query); volume
        is small enough that this is deliberate.
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum:
              - short_pick
              - mispick
              - quality
        - name: state
          in: query
          schema:
            type: string
            enum:
              - open
              - reviewed
              - superseded
        - name: order
          in: query
          description: >-
            Order UUID. Matches exceptions on any pick batch for the order,
            batch-level quality flags included.
          schema:
            type: string
            format: uuid
        - name: manufacturer
          in: query
          description: >-
            Supply partner UUID. Unscoped actors only; scoped keys are pinned to
            their own.
          schema:
            type: string
            format: uuid
        - name: awaiting_supply_partner
          in: query
          description: >-
            `1` keeps only exceptions whose open block is parked on the supply
            partner.
          schema:
            type: string
            enum:
              - '1'
        - name: from
          in: query
          description: Inclusive calendar day (UTC) on the exception's created_at.
          schema:
            type: string
            format: date
        - name: to
          in: query
          description: Inclusive calendar day (UTC) on the exception's created_at.
          schema:
            type: string
            format: date
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 25
            maximum: 100
      responses:
        '200':
          description: Exceptions with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    description: '[] when empty.'
                    items:
                      $ref: '#/components/schemas/PickException'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/exceptions/{id}:
    get:
      tags:
        - Exceptions
      summary: Short-pick exception detail (#1416)
      description: |
        Requires `orders.view`. 404 for a mispick or quality id (each kind has
        its own table and route), for another supply partner's exception, and
        for a non-UUID id.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Exception, its order block, and attached photos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ExceptionDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/exceptions/mispicks/{id}:
    get:
      tags:
        - Exceptions
      summary: Mispick exception detail (#1416)
      description: Same contract as `GET /api/v1/exceptions/{id}` for a mispick id.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Exception, its order block, and attached photos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ExceptionDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/exceptions/quality/{id}:
    get:
      tags:
        - Exceptions
      summary: Quality-flag exception detail (#1416)
      description: >-
        Same contract as `GET /api/v1/exceptions/{id}` for a quality-flag id.
        `block` is null for non-blocking reasons (e.g. `label_illegible`).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Exception, its order block, and attached photos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ExceptionDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/exceptions/{id}/review:
    post:
      tags:
        - Exceptions
      summary: Mark a short-pick exception reviewed
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
      responses:
        '204':
          description: Marked reviewed.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already reviewed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/exceptions/mispicks/{id}/review:
    post:
      tags:
        - Exceptions
      summary: Mark a mispick exception reviewed
      description: |
        `disposition` is one of: `repick_required` (reopens the pick line),
        `ship_as_is`, `cancel_line`, `other`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - disposition
              properties:
                disposition:
                  type: string
                  enum:
                    - repick_required
                    - ship_as_is
                    - cancel_line
                    - other
                note:
                  type: string
      responses:
        '204':
          description: Marked reviewed.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already reviewed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/exceptions/quality/{id}/review:
    post:
      tags:
        - Exceptions
      summary: Mark a quality-flag exception reviewed (#1137)
      description: |
        Office triage of a quality flag (damaged carton, broken seal,
        wrong packaging, illegible label, other). Blocking flags
        (`damaged_in_pack`, `damaged_from_mfg`) route through the
        order_blocks resolution machinery — review with `ship_as_is`,
        `cancel_order`, or `other`. Non-blocking flags get the same
        disposition vocab; reviewing only stamps the exception row.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - disposition
              properties:
                disposition:
                  type: string
                  enum:
                    - ship_as_is
                    - cancel_order
                    - other
                note:
                  type: string
      responses:
        '204':
          description: Marked reviewed.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already reviewed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/quarantine/{lineID}:
    patch:
      tags:
        - Exceptions
      summary: Update inspection_notes on a quarantined receiving line
      description: |
        The one allowed mutation on a posted receiving_line. Photos are
        attached separately via `POST /api/v1/documents/receiving_line/{id}`.
        Requires receiving.qc permission.
      parameters:
        - name: lineID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                inspection_notes:
                  type: string
      responses:
        '204':
          description: Notes updated.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/pick-batches/{batchID}/tracking:
    post:
      tags:
        - Picking
      summary: Capture tracking on a pick batch (mobile-scan twin)
      description: |
        Auto-detects carrier from the tracking-number format. The
        validator (`orders.ValidateForCapture`) requires the tracking
        number to match a known UPS / FedEx / USPS pattern after
        whitespace + dash normalization; SSCC pallet IDs, employee
        badge codes, sub-10 / >40-character strings, and anything
        outside `[A-Z0-9]{10,40}` all return 422 `invalid_tracking`
        with a field-level message in the response body.
        See also the office-correction endpoint
        `PATCH /api/v1/pick-batches/{batchID}/tracking` (Phase 2b) which
        accepts an explicit carrier and is used by office staff after the
        fact — that path is intentionally lenient (the office is the
        escape hatch for unusual carriers).
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - tracking_number
              properties:
                tracking_number:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Tracking captured (carrier auto-detected).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch_id:
                        type: string
                        format: uuid
                      tracking_number:
                        type: string
                      carrier:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Tracking number failed validation. Response body has
            `error.code = "invalid_tracking"` and a human-readable
            message tuned to the failure (empty / too short / too long
            / invalid chars / unrecognized carrier).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
    patch:
      tags:
        - Shipping
      summary: Set or correct the tracking number / carrier on a pick batch
      description: |
        Office correction path for shipment tracking. Carrier auto-detects
        from the tracking number when omitted. Requires orders.edit
        or orders.tracking_correct
        permission.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - tracking_number
              properties:
                tracking_number:
                  type: string
                  minLength: 1
                carrier:
                  type: string
                  enum:
                    - ups
                    - fedex
                    - usps
                    - other
      responses:
        '200':
          description: Tracking updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch_id:
                        type: string
                        format: uuid
                      tracking_number:
                        type: string
                      carrier:
                        type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/pick-batches/{batchID}/parcels:
    get:
      tags:
        - Shipping
      summary: List the boxes (parcels) on a shipment
      description: |
        Returns the live boxes on a pick batch, each with its own tracking
        number + carrier, ordered by box sequence. Requires orders.view
        permission. A shipment with no captured boxes returns an empty list.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The live boxes on the shipment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch_id:
                        type: string
                        format: uuid
                      parcels:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            parcel_seq:
                              type: integer
                            tracking_number:
                              type: string
                            carrier:
                              type: string
                            captured_at:
                              type: string
                              format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Shipping
      summary: Add a box (parcel) to a shipment
      description: |
        Appends a box with its own carrier tracking number. Carrier
        auto-detects from the tracking number when omitted. Idempotent on
        (carrier, tracking_number) — re-posting the same number returns the
        existing box instead of creating a duplicate. Requires orders.ship
        permission. This is the documented equivalent of the desktop
        ship-station "Add box" affordance.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - tracking_number
              properties:
                tracking_number:
                  type: string
                  minLength: 1
                carrier:
                  type: string
                  enum:
                    - ups
                    - fedex
                    - usps
                    - other
      responses:
        '201':
          description: Box added (or the existing box, when idempotent).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      parcel_id:
                        type: string
                        format: uuid
                      parcel_seq:
                        type: integer
                      batch_id:
                        type: string
                        format: uuid
                      tracking_number:
                        type: string
                      carrier:
                        type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: >-
            The tracking number failed validation (unrecognized format and no
            carrier supplied).
  /api/v1/pick-batches/{batchID}/parcels/{parcelID}:
    patch:
      tags:
        - Shipping
      summary: Correct one box's tracking number / carrier
      description: >
        Office correction for a single box. Carrier auto-detects when

        omitted. Requires orders.edit or orders.tracking_correct permission.
        Returns 404 when the parcel

        does not belong to the batch (no ID enumeration).
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: parcelID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - tracking_number
              properties:
                tracking_number:
                  type: string
                  minLength: 1
                carrier:
                  type: string
                  enum:
                    - ups
                    - fedex
                    - usps
                    - other
      responses:
        '200':
          description: Box updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      parcel_id:
                        type: string
                        format: uuid
                      tracking_number:
                        type: string
                      carrier:
                        type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Shipping
      summary: Void (remove) a box from a shipment
      description: >
        Soft-voids one box (the row is retained for audit; carrier-event

        replays stay idempotent). Requires orders.edit or
        orders.tracking_correct permission. Returns

        404 when the parcel does not belong to the batch.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: parcelID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Box voided.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/admin/shipments/import:
    post:
      tags:
        - Shipping
      summary: Bulk-import shipment tracking from a CSV
      description: |
        One-shot parse + match + stage + commit. Skips the web's preview
        step — n8n / scripts dedupe upstream. Multipart upload (50 MB cap).
        If any row matches multiple pick batches the endpoint returns 422
        ambiguous_rows with a `import_id` + per-row details so the caller
        can resolve duplicates and retry. If any row would overwrite an
        existing tracking number, returns 422 confirm_overwrites_required
        unless `confirm_overwrites=true` is set on the form.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - file
              properties:
                file:
                  type: string
                  format: binary
                  description: CSV file with order_reference + tracking_number columns.
                confirm_overwrites:
                  type: string
                  description: >-
                    Pass `true` (or `1` / `on`) to allow overwriting existing
                    tracking numbers.
      responses:
        '201':
          description: Import committed; counts returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      import_id:
                        type: string
                        format: uuid
                      filename:
                        type: string
                      rows_total:
                        type: integer
                      rows_applied:
                        type: integer
                      rows_voided:
                        type: integer
                      rows_no_change:
                        type: integer
                      rows_unmatched:
                        type: integer
                      rows_out_scope:
                        type: integer
                      rows_error:
                        type: integer
        '400':
          description: Missing file, malformed multipart, or unparseable CSV.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Already-committed import (re-submit).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: >-
            Ambiguous rows or unconfirmed overwrites — retry with deduped data
            or confirm_overwrites=true.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    $ref: '#/components/schemas/APIError'
                  import_id:
                    type: string
                    format: uuid
                  ambiguous:
                    type: array
                    items:
                      type: object
                  overwrites:
                    type: array
                    items:
                      type: object
  /api/v1/orders/{orderID}/pick-batches:
    get:
      tags:
        - Picking
      summary: List an order's pick batches (#1396)
      description: |
        Every pick batch on the order in every state, with tracking and parcel
        detail, newest-packed first. Requires `orders.view`. Supply-partner-
        scoped actors get 404 for another supply partner's order.
      parameters:
        - name: orderID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The order's pick batches.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      order_id:
                        type: string
                        format: uuid
                      pick_batches:
                        type: array
                        items:
                          $ref: '#/components/schemas/OrderPickBatch'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Picking
      summary: Start (or resume) a pick batch for an order
      description: |
        Resumes the existing in-progress pick batch for the order, or
        creates a new one if none exists. Requires orders.ship permission
        and access to the order's manufacturer.
      parameters:
        - name: orderID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '201':
          description: Pick session started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Order is not in a status from which picking can start.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}:
    get:
      tags:
        - Picking
      summary: Get a pick batch (#1396)
      description: |
        Pick-batch detail in the pick-session shape - lines with picked/short
        state, tracking, parcels, staging. Requires `orders.view` (the write
        endpoints require orders.ship). Supply-partner-scoped actors get 404
        for another supply partner's batch.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The pick batch.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PickBatchDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/v1/pick-batches/{batchID}/pick:
    post:
      tags:
        - Picking
      summary: Record a successful pick on a line
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - order_line_id
                - qty_picked
              properties:
                order_line_id:
                  type: string
                  format: uuid
                qty_picked:
                  type: integer
                  minimum: 1
                  description: >-
                    Must equal the line's qty_to_pick exactly — partial
                    quantities go through the short-pick endpoint. Mismatches
                    return 400 qty_mismatch; an already-picked or short line
                    returns 409 line_not_pickable.
      responses:
        '200':
          description: Pick recorded; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/pick-batches/{batchID}/short:
    post:
      tags:
        - Picking
      summary: Record a short pick (qty_actual < qty_to_pick) with reason
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - order_line_id
                - qty_actual
                - reason
              properties:
                order_line_id:
                  type: string
                  format: uuid
                qty_actual:
                  type: integer
                  minimum: 0
                  description: >-
                    Quantity actually pulled; must be between 0 and qty_to_pick
                    - 1. Negative values return 400 qty_mismatch.
                reason:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Short pick recorded; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/pick-batches/{batchID}/lines/{pblID}/undo-short:
    post:
      tags:
        - Picking
      summary: Undo a short-pick the picker just recorded
      description: |
        Reverts a short-pick exception while it is still 'open'. Refused
        once the office has reviewed the exception (409 already_reviewed).
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: pblID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                superseded_reason:
                  type: string
                  description: One of `found_it`, `miscounted_corrected`, or `other`.
      responses:
        '200':
          description: Short undone; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Short-pick already reviewed by office.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/mispick:
    post:
      tags:
        - Picking
      summary: Record a mispick exception
      description: |
        Flags a wrong-item pick. Photo evidence is uploaded separately via
        `POST /api/v1/documents/mispick_exception/{exception_id}` after the
        exception_id is returned from this call.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - order_line_id
                - flag_reason
              properties:
                order_line_id:
                  type: string
                  format: uuid
                flag_reason:
                  type: string
                  enum:
                    - wrong_item_known
                    - wrong_item_unknown
                    - other
                flag_note:
                  type: string
                actual_item_id:
                  type: string
                  format: uuid
                  nullable: true
                actual_qty:
                  type: integer
                  minimum: 0
                  nullable: true
      responses:
        '201':
          description: Mispick exception created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      exception_id:
                        type: string
                        format: uuid
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/pick-batches/{batchID}/lines/{pblID}/undo-mispick:
    post:
      tags:
        - Picking
      summary: Undo an open mispick flag
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: pblID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                superseded_reason:
                  type: string
                  description: Defaults to `found_correct_item` when omitted.
      responses:
        '200':
          description: Mispick undone; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Mispick already reviewed by office.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/quality:
    post:
      tags:
        - Picking
      summary: Record a quality-flag exception (#1137)
      description: >
        Flags a condition issue (damage, broken seal, wrong packaging,

        illegible label, other) at pack stage. Distinct from mispick

        (wrong identity) and short-pick (wrong count). Photo evidence is

        uploaded separately via `POST
        /api/v1/documents/quality_exception/{exception_id}`

        after the exception_id is returned from this call. Omit

        `order_line_id` for a batch-level flag (a finding that covers

        the whole shipment, not one specific line). Blocking reasons

        (`damaged_in_pack`, `damaged_from_mfg`) create an `order_blocks`

        row in the same tx; non-blocking reasons leave pack/ship

        ungated.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - flag_reason
              properties:
                order_line_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Omit (or null) for a batch-level flag.
                flag_reason:
                  type: string
                  enum:
                    - damaged_in_pack
                    - damaged_from_mfg
                    - packaging_wrong
                    - label_illegible
                    - other
                flag_note:
                  type: string
                  description: Required when `flag_reason` is `other`.
      responses:
        '201':
          description: Quality exception created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      exception_id:
                        type: string
                        format: uuid
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/pick-batches/{batchID}/photo:
    post:
      tags:
        - Picking
      summary: Upload a proof-of-pack photo (#1149)
      description: |
        Attaches a photo to the pick_batch row as a happy-path record of
        "this is what went in the box." Distinct from #1137's
        quality-flag photos, which are tied to an open exception.
        Multiple photos allowed; the field is optional from the UI side
        (no error if a packer marks the batch packed without one).
        Refused with 409 once the batch is shipped or cancelled —
        post-ship the record is frozen, and the office attaches photos
        manually via the existing per-batch document upload route.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - photo
              properties:
                photo:
                  type: string
                  format: binary
                  description: Image file, up to 50 MB. JPEG/PNG/etc.
      responses:
        '201':
          description: Photo stored; returns the new document ID.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      document_id:
                        type: string
                        format: uuid
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Batch is shipped or cancelled; pack-photo capture is frozen.
        '413':
          description: Upload exceeds the 50 MB cap.
  /api/v1/pick-batches/{batchID}/quality/{qualityID}/undo:
    post:
      tags:
        - Picking
      summary: Undo an open quality flag (#1137)
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: qualityID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                superseded_reason:
                  type: string
                  enum:
                    - found_intact
                    - flag_in_error
                    - other
                  description: Defaults to `found_intact` when omitted.
      responses:
        '200':
          description: Quality flag undone; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Quality flag already reviewed by office.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/skip:
    post:
      tags:
        - Picking
      summary: Skip a line (defer until later in the batch)
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - order_line_id
              properties:
                order_line_id:
                  type: string
                  format: uuid
      responses:
        '200':
          description: Line skipped; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/pick-batches/{batchID}/complete:
    post:
      tags:
        - Picking
      summary: Mark picking complete (status → completed)
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Batch picked; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Batch could not be completed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/lines/{pblID}/verify:
    post:
      tags:
        - Picking
      summary: Record the pack-station QC tick for one line
      description: |
        Stamps `pack_verified_at` / `pack_verified_by` on the line the
        moment the packer confirms the goods match it (#1426), so the tick
        survives navigating away (flag, photo) and shows on any device.
        Idempotent — a repeat call keeps the original stamp. Only allowed
        while the batch is at the pack station (`status = completed`).
        Lines stamped here are exempt from `verified_line_ids` on
        `/pick-batches/{batchID}/pack`. The office reopening the line for a
        re-pick (mispick / block `repick_required`) clears the stamp.
        UI twin: the per-line box on the mobile pack screen.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: pblID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: >-
            Line stamped; returns updated session (`Lines[].PackVerifiedAt`
            set).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Batch not visible to the caller, or the line is not on this batch.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '409':
          description: >-
            `batch_not_packing` — the batch is not at the pack station (still
            picking, already packed, shipped or cancelled).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/lines/{pblID}/unverify:
    post:
      tags:
        - Picking
      summary: Clear the pack-station QC tick for one line
      description: |
        Clears `pack_verified_at` / `pack_verified_by` on the line (#1426).
        Same guards as `/verify`; clearing an already-clear line is a
        no-op 200. UI twin: unticking the per-line box on the mobile pack
        screen.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: pblID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: >-
            Line cleared; returns updated session (`Lines[].PackVerifiedAt`
            null).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Batch not visible to the caller, or the line is not on this batch.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '409':
          description: '`batch_not_packing` — the batch is not at the pack station.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/pack:
    post:
      tags:
        - Picking
      summary: >-
        Mark a batch as packed (every line verified; cartons only — weight moves
        to ship)
      description: |
        The pack station is the QC check (#1413): every line of the batch
        must have been checked against the goods before it packs. A line
        counts as verified when it was stamped earlier via
        `POST /pick-batches/{batchID}/lines/{pblID}/verify` (#1426) or when
        its `Lines[].PickBatchLineID` is listed in `verified_line_ids`
        here. Any line still unverified is refused with 422
        `lines_unverified` and the batch stays `completed`; on success every
        line carries `PackVerifiedAt`, stamped in the same transaction as
        the status flip. Ids not on the batch are ignored. (The start-batch
        201 carries empty `PickBatchLineID`s — take them from any later
        lifecycle response, e.g. `/complete`.)

        Carton count is captured at pack time. Weight is captured at the ship
        handoff (#1136) — see `/api/v1/pick-batches/{batchID}/ship`.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - cartons
              properties:
                cartons:
                  type: integer
                  minimum: 1
                  maximum: 50
                  description: >-
                    Carton count for the packed batch. #1120 caps the range to
                    catch fat-finger entries on the mobile keypad.
                verified_line_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
                  description: >-
                    #1413: pick_batch_lines ids (`Lines[].PickBatchLineID`)
                    confirmed against the goods at pack time. Optional since
                    #1426 — lines already stamped via `.../lines/{pblID}/verify`
                    are exempt, so this only needs to cover what is still
                    unstamped. Extras are ignored.
                moved_to_location_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: >-
                    #1377 Phase 3: optional carton destination — an active
                    shipping-type location (lane). Omit to leave the batch where
                    it was packed. If the lane is invalid the pack still applies
                    and the call returns 422 lane_move_failed.
      responses:
        '200':
          description: Batch packed; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: |
            Batch is not in a packable status (`pack_failed`), a line was not
            verified (`lines_unverified`, #1413 — nothing applied), or the
            pack applied but the optional lane move failed (`lane_move_failed`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/stage:
    post:
      tags:
        - Picking
      summary: Record which staging slot a completed batch physically sits on
      description: |
        Staging slots (#1377): after picking completes, the picked stack is
        placed on a labeled staging slot (a staging-type warehouse location)
        so the packer — or the will-call counter — can find it. A null or
        omitted `location_id` records the "handed directly to packer"
        escape path. Calling again re-stages (moves) the batch; the
        previous slot frees automatically. Only one live (completed) batch
        can occupy a slot at a time.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                location_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: >-
                    Active staging-type warehouse location. Null = handed
                    directly to packer.
      responses:
        '200':
          description: Batch staged; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '400':
          description: Location is not an active staging slot (or invalid body).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Another live batch already occupies the slot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: Batch is not in a stageable status (must be completed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/restock:
    post:
      tags:
        - Picking
      summary: Confirm a cancelled-while-staged batch's stack was returned to the shelf
      description: |
        Restock confirmation (#1385): when an order is cancelled while its
        picked stack sits on a staging slot, the slot renders as "needs
        clearing" and a Return to shelf task surfaces until someone
        physically puts the stock back and confirms. This endpoint stamps
        `restocked_at`/`restocked_by` on the batch, freeing the slot for
        staging and clearing the task. No inventory numbers change —
        on-hand was never decremented (that happens at ship); this is a
        physical-handling confirmation, audit-logged against the order.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Restock confirmed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum:
                          - restocked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            Batch is not awaiting restock (not cancelled, never staged on a
            slot, or already confirmed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/staging-slots:
    get:
      tags:
        - Picking
      summary: List staging slots with live occupancy
      description: |
        Every staging-type warehouse location the slot grid shows: all
        active slots, plus retired slots still holding a live batch or an
        uncleared cancelled stack (visible until freed/cleared). Sorted
        naturally (S2 before S10). `needs_clearing` (#1385) marks a slot
        physically holding a cancelled batch's stack awaiting its
        return-to-shelf confirmation — not offered as a staging target.
      responses:
        '200':
          description: Slot occupancy list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  slots:
                    type: array
                    items:
                      type: object
                      properties:
                        location_id:
                          type: string
                          format: uuid
                        code:
                          type: string
                        is_active:
                          type: boolean
                        occupied_by_batch_id:
                          type: string
                          format: uuid
                          nullable: true
                        order_id:
                          type: string
                          format: uuid
                          nullable: true
                        order_number:
                          type: integer
                          nullable: true
                        customer_name:
                          type: string
                          nullable: true
                        customer_po_number:
                          type: string
                          nullable: true
                          description: >-
                            Customer PO on the staged order; omitted when unset
                            (#1405).
                        manufacturer_auth:
                          type: string
                          nullable: true
                          description: >-
                            Supply Partner Order # on the staged order; omitted
                            when unset (#1405).
                        staged_at:
                          type: string
                          format: date-time
                          nullable: true
                        needs_clearing:
                          type: boolean
                          description: >-
                            A cancelled batch's stack is still on the slot
                            awaiting restock confirmation (#1385).
                        clearing_batch_id:
                          type: string
                          format: uuid
                          nullable: true
                        clearing_order_number:
                          type: integer
                          nullable: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/pick-batches/{batchID}/ship:
    post:
      tags:
        - Picking
      summary: Ship a packed batch (collapsed mobile + desktop ship endpoint)
      description: |
        Collapses three web routes into a single API endpoint:

        - With `tracking_number` → captures tracking + confirms shipment
          (the mobile ship-with-scan + desktop ship-confirm flows).
        - Without a body → ships with whatever tracking is already on the
          batch (the desktop ship-with-existing flow). 422 missing_tracking
          if no tracking is present — caller can either supply tracking or
          POST `/skip-shipping`.

        Carrier auto-detects from the tracking number when omitted. The
        auto-detect path runs `orders.ValidateForCapture`, so unrecognized
        labels (SSCC pallets, badges, sub-10 strings) return 422
        `invalid_tracking`. When the caller supplies an explicit `carrier`,
        the auto-detect gate is skipped — the caller has taken responsibility
        for the carrier choice (same semantics as the office PATCH endpoint).

        Optional `weight_lbs` captures shipped package weight at the carrier
        handoff (#1136). When > 0 it is recorded in the `shipped` audit-log
        payload; when omitted or 0 the audit payload carries no weight key.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                tracking_number:
                  type: string
                carrier:
                  type: string
                  enum:
                    - ups
                    - fedex
                    - usps
                    - other
                weight_lbs:
                  type: integer
                  minimum: 0
      responses:
        '200':
          description: Shipped; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Batch already shipped.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: |
            One of: order not in a shippable status; no tracking on batch
            and none provided (`missing_tracking`); auto-detect tracking
            failed validation (`invalid_tracking`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/skip-shipping:
    post:
      tags:
        - Picking
      summary: Flag for office and ship without tracking
      description: |
        Desktop ship-station "office follows up" path: flags the batch for
        office tracking entry and confirms the shipment. The office picks
        it up from `/admin/shipments/pending-tracking`.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Flagged + shipped; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Batch already shipped.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: Order not in a shippable status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/pickup:
    post:
      tags:
        - Picking
      summary: WILL CALL customer-pickup handoff
      description: |
        Customer is at the dock. Captures the picker-up name and (optional)
        notes; runs the same inventory move as ship-confirm but stamps
        pickup metadata on the order. Requires the order to have
        freight_terms='WILL CALL' and the batch to be in 'completed'.

        #1371: a proof-of-pickup photo is REQUIRED — the batch must already
        have at least one photo uploaded via POST
        /api/v1/pick-batches/{batchID}/pickup-photo, else 400
        `pickup_photo_required`.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - picked_up_by_name
              properties:
                picked_up_by_name:
                  type: string
                  minLength: 1
                notes:
                  type: string
      responses:
        '200':
          description: Pickup confirmed; returns updated session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        $ref: '#/components/schemas/PickSession'
        '400':
          description: |
            Missing name (`bad_request`) or no proof-of-pickup photo on the
            batch (`pickup_photo_required`, #1371).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Order not WILL CALL or batch not in completed status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/pick-batches/{batchID}/pickup-photo:
    post:
      tags:
        - Picking
      summary: Upload a proof-of-pickup photo (#1371)
      description: |
        Attaches a photo documenting the WILL CALL handoff (signed receipt,
        ID checked, or the goods leaving) to the pick_batch row, tagged
        `description='Pickup photo'`. Distinct from proof-of-pack photos
        (#1149), which share the pick_batch anchor but a different
        description. At least one pickup photo is a prerequisite for the
        pickup-confirm endpoint above. Multiple photos allowed. Refused with
        409 once the batch is picked up or cancelled.
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - photo
              properties:
                photo:
                  type: string
                  format: binary
                  description: Image file, up to 50 MB. JPEG/PNG/etc.
      responses:
        '201':
          description: Photo stored; returns the new document ID.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      document_id:
                        type: string
                        format: uuid
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Batch is picked up or cancelled; pickup-photo capture is frozen.
        '413':
          description: Upload exceeds the 50 MB cap.
  /api/v1/pick-batches/{batchID}/tracking/flag-for-office:
    post:
      tags:
        - Picking
      summary: Flag a batch for office tracking-entry follow-up (idempotent)
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Flagged.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/admin/users:
    get:
      tags:
        - Admin Users
      summary: List users (#1396)
      description: |
        Every user with role, supply-partner scope, MFA/PIN state, and last
        login. Requires the system_admin role (like every /admin endpoint).
        Unpaginated - the internal staff list is small and bounded.
      responses:
        '200':
          description: All users.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      users:
                        type: array
                        items:
                          $ref: '#/components/schemas/AdminUserListItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Admin Users
      summary: Create a user (standard or consignor)
      description: |
        Creates a user. Branches on the resolved role name:
          - **consignor** → invite flow. `email` and `manufacturer_id`
            are required; `password` is ignored. Returns `user`,
            `invite_url`, and `email_sent`.
          - **standard** (any non-consignor role) → direct-create.
            `password` is required and validated. Returns `user`.

        Requires the `system_admin` role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - username
                - display_name
                - role_id
              properties:
                username:
                  type: string
                display_name:
                  type: string
                role_id:
                  type: string
                  format: uuid
                email:
                  type: string
                  description: Required for consignor; optional otherwise.
                password:
                  type: string
                  description: Required for non-consignor roles.
                manufacturer_id:
                  type: string
                  format: uuid
                  description: Required for consignor users.
      responses:
        '201':
          description: >-
            User created. Consignor branch additionally returns invite_url +
            email_sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      user:
                        $ref: '#/components/schemas/AdminUser'
                      invite_url:
                        type: string
                        nullable: true
                      email_sent:
                        type: boolean
                        nullable: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: >-
            Duplicate username (`conflict`) or domain rejection
            (`create_failed`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/admin/users/{id}:
    patch:
      tags:
        - Admin Users
      summary: Update a user
      description: |
        PATCH semantics — every body field is optional. Omitted fields
        are unchanged. `manufacturer_id` is tri-state: omit = no change,
        explicit `null` = clear (refused on consignors), string = set.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name:
                  type: string
                email:
                  type: string
                role_id:
                  type: string
                  format: uuid
                manufacturer_id:
                  type: string
                  nullable: true
                  description: >-
                    string = set; null = clear (refused on consignors); omit =
                    no change.
      responses:
        '200':
          description: Updated user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      user:
                        $ref: '#/components/schemas/AdminUser'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Domain rejection (e.g. clearing manufacturer on a consignor).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/admin/users/{id}/resend-invite:
    post:
      tags:
        - Admin Users
      summary: Revoke open invites and send a fresh one
      description: |
        Revokes any open invite for the user and creates a new one,
        emailing the link. Returns the link in the response body so
        log-mailer or test setups can still deliver it. Mailer failure
        does NOT fail the call; check `email_sent`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Invite resent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      invite_url:
                        type: string
                      email_sent:
                        type: boolean
                      resent:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: User has no email on file.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/admin/users/{id}/deactivate:
    post:
      tags:
        - Admin Users
      summary: Deactivate a user
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Updated user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      user:
                        $ref: '#/components/schemas/AdminUser'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/admin/users/{id}/activate:
    post:
      tags:
        - Admin Users
      summary: Activate a user
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Updated user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      user:
                        $ref: '#/components/schemas/AdminUser'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/admin/users/{id}/reset-mfa:
    post:
      tags:
        - Admin Users
      summary: Wipe a user's MFA enrollment + lockout
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: MFA reset.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/admin/users/{id}/unlock-mfa:
    post:
      tags:
        - Admin Users
      summary: Clear a user's MFA failure counter / lockout (without disenrolling)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Account unlocked.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/admin/users/{id}/set-password:
    post:
      tags:
        - Admin Users
      summary: Set a new password for a user (admin)
      description: >-
        Sets a new password immediately. Validates complexity, clears any
        account lockout, and stamps password_changed_at — which invalidates the
        target user's existing sessions (tokens issued before the change are
        rejected). Same system_admin gate as the other admin user endpoints.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - password
              properties:
                password:
                  type: string
                  description: >-
                    New password. Must pass complexity validation: min 8 chars,
                    at least one uppercase, lowercase, digit, and special
                    character, and not a common password.
      responses:
        '204':
          description: Password set.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/admin/config:
    patch:
      tags:
        - Admin Users
      summary: Update system configuration
      description: |
        PATCH semantics — every body field is optional. Omitting a field
        leaves it unchanged. `receiving_stuck_threshold_hours` is bounded
        to 1..168 (web silently drops out-of-range; the API returns
        `400 bad_request` so callers can see their bug). Sequences cannot
        be moved backwards: `422 sequence_backwards`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                order_seq:
                  type: integer
                  format: int64
                  description: Set order_number_seq forward (cannot go backwards).
                release_seq:
                  type: integer
                  format: int64
                  description: Set release_number_seq forward (cannot go backwards).
                cycle_count_items_per_batch:
                  type: integer
                  minimum: 1
                require_cycle_count_before_orders:
                  type: boolean
                document_retention_years:
                  type: integer
                  minimum: 1
                receiving_stuck_threshold_hours:
                  type: integer
                  minimum: 1
                  maximum: 168
                price_visibility_enabled:
                  type: boolean
                org_name:
                  type: string
                  description: >-
                    Operating company / sender display name. Trimmed on write;
                    an empty string resets to the "ConsignTrak" default on read.
      responses:
        '200':
          description: Post-update config bundle.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      config:
                        $ref: '#/components/schemas/ConfigBundle'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: '`sequence_backwards` when a sequence is moved backwards.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/account/security/recovery-codes:
    post:
      tags:
        - Account Security
      summary: Regenerate the caller's MFA recovery codes
      description: |
        Step-up: requires `{otp}` in the body when MFA is enrolled.
        Returns 10 freshly-generated recovery codes once. The response
        is `Cache-Control: private, no-store` so the codes never persist
        in shared caches.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                otp:
                  type: string
                  description: Required when caller has MFA enrolled.
      responses:
        '200':
          description: Fresh recovery codes (shown once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      recovery_codes:
                        type: array
                        items:
                          type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: '`invalid_otp` when the OTP is wrong or replayed.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: '`not_enrolled` when caller has no MFA enrollment.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/account/security/mfa:
    delete:
      tags:
        - Account Security
      summary: Disable the caller's MFA enrollment
      description: |
        Step-up: requires `{otp}` in the body when MFA is enrolled.
        Wipes TOTP secret, recovery codes, and trusted devices.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                otp:
                  type: string
      responses:
        '204':
          description: MFA disabled.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: '`invalid_otp`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: '`not_enrolled` when caller has no MFA enrollment.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/account/security/devices/{id}/revoke:
    post:
      tags:
        - Account Security
      summary: Revoke one of the caller's trusted MFA devices
      description: |
        Removes a remembered-device row. The device must belong to the
        caller; passing a device ID owned by another user yields 404
        (deliberate — RevokeMFADevice scopes by user_id).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Device revoked.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/reports/eod-shipment:
    get:
      tags:
        - Reporting
      summary: End-of-day shipment report
      description: |
        Returns the orders that physically shipped from the warehouse on
        the given date, grouped by supply partner. Excludes 100%
        direct-ship orders; on split orders, returns only the
        warehouse-fulfilled lines.

        `date` is a civil day in the warehouse's local timezone
        (for example `America/New_York`). Defaults to today.
        Invalid `date` falls back to today (does NOT 400).

        Permission: `history.view`. Consignor-scoped or manufacturer-
        scoped users get 404 — this is a warehouse-wide activity
        view, not a tenant-scoped one.
      parameters:
        - name: date
          in: query
          required: false
          schema:
            type: string
            format: date
            example: '2026-05-12'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      date:
                        type: string
                        format: date
                        description: The civil day the report covers.
                      summary:
                        $ref: '#/components/schemas/EODSummary'
                      manufacturers:
                        type: array
                        items:
                          $ref: '#/components/schemas/EODManufacturerSection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Caller lacks `history.view`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '404':
          description: Caller is manufacturer-scoped (consignor or scoped admin).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/admin/carrier-credentials/{mfgID}/{carrier}:
    parameters:
      - name: mfgID
        in: path
        required: true
        schema:
          type: string
          format: uuid
        description: Supply partner (manufacturer) ID.
      - name: carrier
        in: path
        required: true
        schema:
          type: string
          enum:
            - fedex
            - ups
    get:
      tags:
        - Carrier Credentials
      summary: Read carrier credentials (no secrets)
      description: |
        Returns the read-side projection of the credential row for the
        given supply partner + carrier. Never includes secrets. Requires
        `system_admin` role; additionally, an API key scoped to supply
        partner A cannot read supply partner B even with the role
        (`forbidden_cross_tenant`).
      responses:
        '200':
          description: Credentials configured
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CarrierCredentialOutput'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Carrier Credentials
      summary: Create or update carrier credentials
      description: |
        Upsert. The carrier path segment selects the variant schema for
        the request body (FedEx vs UPS). Returns the read-side projection
        on success; secrets are not echoed back. To obtain the just-set
        secret, use the rotate endpoint instead (the reveal-once response
        carries it).

        Role: `system_admin`. Per-tenant scope check applies to API keys.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CarrierCredentialInput'
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CarrierCredentialOutput'
        '400':
          description: Invalid JSON or unknown fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Variant validation failure (missing required field for the carrier)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
    delete:
      tags:
        - Carrier Credentials
      summary: Delete carrier credentials
      description: |
        Removes the credential row. Subsequent FedEx webhook deliveries
        for this tenant return 410 (Phase 3); UPS poller skips this tenant
        on the next cycle (Phase 4). Not idempotent — 404 if no row exists.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/admin/carrier-credentials/{mfgID}/{carrier}/rotate:
    parameters:
      - name: mfgID
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: carrier
        in: path
        required: true
        schema:
          type: string
          enum:
            - fedex
            - ups
    post:
      tags:
        - Carrier Credentials
      summary: Rotate carrier credentials (returns just-set secret once)
      description: |
        Rotates the credential secret to the value supplied in the body.
        The prior secret stops working immediately. The response includes
        the just-set secret in `hmac_secret_reveal_once` (FedEx) or
        `oauth_client_secret_reveal_once` (UPS) — **this is the only
        response that ever carries the plaintext secret**. Subsequent
        reads via GET never include it; if lost, rotate again.

        Returns 404 when no existing row to rotate (use POST to create).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CarrierCredentialInput'
      responses:
        '200':
          description: Rotated; secret returned exactly once
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CarrierCredentialReveal'
        '400':
          description: Invalid JSON
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No existing credentials to rotate
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '422':
          description: Variant validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
  /api/v1/webhooks/fedex/shipments/{tenantToken}:
    parameters:
      - name: tenantToken
        in: path
        required: true
        description: |
          Per-credential opaque URL token generated by ConsignTrak on
          FedEx-variant `carrier_credentials` Upsert/Rotate. Selects which
          tenant's HMAC secret to verify the body against. Not a secret —
          HMAC verification is the actual auth surface — but unguessable.
        schema:
          type: string
    post:
      tags:
        - Carrier Webhooks
      summary: FedEx AIV shipment-event receiver
      description: |
        Inbound push-webhook receiver for FedEx Shipment Visibility AIV
        (issue #1143 Phase 3). FedEx posts shipment-event envelopes
        (label_creation, in_transit, etc.) with an `Fdx-Signature` HMAC
        header; ConsignTrak verifies the signature in constant time,
        dedups against `carrier_event_log.(carrier, carrier_event_id)`,
        normalizes the payload, and runs `shipimport.Stage + MatchMulti
        + Commit` to update the matching pick batch's `tracking_number`,
        `tracking_carrier`, and `tracking_captured_at`.

        Only `LABEL_CREATION` events drive tracking writes. Other event
        types are signature-verified, ignored, and acknowledged with 200
        `{"status":"ignored"}` so FedEx doesn't retry them.

        Idempotency: replays of the same `eventId` short-circuit on the
        ON CONFLICT DO NOTHING path and return 200 `{"status":"replayed"}`
        without re-running Stage/Match/Commit. Bad-signature deliveries
        write a `carrier_transport_log` row with outcome
        `signature_rejected` and return 401 without any other DB write.
        Tombstoned (deleted) tenant tokens return 410 Gone so FedEx stops
        retrying.
      security:
        - FdxSignatureHMAC: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - eventId
                - eventType
                - trackingNumber
              properties:
                eventId:
                  type: string
                  description: >-
                    Carrier-supplied unique event id; used as the idempotency
                    key on carrier_event_log.
                eventType:
                  type: string
                  description: >-
                    AIV event type. LABEL_CREATION drives a tracking write;
                    others are signature-verified and acknowledged but not
                    normalized.
                eventTimestamp:
                  type: string
                  format: date-time
                accountNumber:
                  type: string
                  description: >-
                    FedEx account number; informational. The URL tenantToken is
                    the actual tenant discriminator.
                trackingNumber:
                  type: string
                shipDate:
                  type: string
                  format: date
                customerReferences:
                  type: array
                  items:
                    type: object
                    required:
                      - type
                      - value
                    properties:
                      type:
                        type: string
                        enum:
                          - CUSTOMER_REFERENCE
                          - PO_NUMBER
                          - INVOICE_NUMBER
                      value:
                        type: string
                recipient:
                  type: object
                  properties:
                    name:
                      type: string
                    streetLines:
                      type: array
                      items:
                        type: string
                    city:
                      type: string
                    stateOrProvinceCode:
                      type: string
                    postalCode:
                      type: string
                    countryCode:
                      type: string
      responses:
        '200':
          description: >-
            Event processed (applied, no-change, unmatched, ignored, or
            replayed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - applied
                      - replayed
                      - ignored
                  outcome:
                    type: string
                    enum:
                      - applied
                      - no_change
                      - overwrite_blocked
                      - out_of_scope
                      - unmatched
                      - error
                  eventId:
                    type: string
                  eventType:
                    type: string
                  summary:
                    type: object
                    properties:
                      Total:
                        type: integer
                      Applied:
                        type: integer
                      Voided:
                        type: integer
                      NoChange:
                        type: integer
                      Unmatched:
                        type: integer
                      OutOfScope:
                        type: integer
                      Error:
                        type: integer
        '401':
          description: |
            HMAC signature verification failed. Body is discarded; no
            carrier_event_log row is written. One carrier_transport_log
            row is appended with outcome `signature_rejected`.
        '410':
          description: |
            Tenant token does not resolve to an active credential row
            (deleted or never existed). FedEx should stop retrying.
        '413':
          description: Request body exceeds 1 MiB cap
        '422':
          description: Body verified by HMAC but failed to decode as a valid AIV envelope
  /api/v1/admin/carrier-poll/{mfgID}/{carrier}:
    parameters:
      - name: mfgID
        in: path
        required: true
        description: Manufacturer (supply partner) ID to poll for.
        schema:
          type: string
          format: uuid
      - name: carrier
        in: path
        required: true
        description: |
          Carrier identifier. Currently only `ups` is implemented (FedEx
          is push-based — its production code path is the AIV webhook,
          not a poller).
        schema:
          type: string
          enum:
            - ups
            - fedex
    post:
      tags:
        - Admin
      summary: Manually trigger one carrier poll cycle for a supply partner
      description: |
        Issue #1143 Phase 4. Runs one UPS Tracking-by-reference cycle for
        the named supply partner: iterates the tenant's open orders,
        queries UPS for each, and applies any matching tracking numbers
        via the same Stage + MatchMulti + Commit path the background
        scheduler uses. **API-only — no UI twin.** The production code
        path is the server's background scheduler (60s
        default cadence); this endpoint exists for ops debugging and the
        e2e harness.

        `system_admin` only. FedEx variant returns 422 (push lane has no
        poll cycle). Returns 503 when carrier ingestion is not configured.
      security:
        - BearerAuth: []
      responses:
        '200':
          description: >-
            Poll cycle completed (may have written zero or many tracking
            numbers)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                  manufacturer_id:
                    type: string
                    format: uuid
                  carrier:
                    type: string
                    enum:
                      - ups
        '400':
          description: mfgID missing or malformed.
        '422':
          description: FedEx variant — push-only, no poll cycle exists.
        '502':
          description: |
            UPS API returned an unrecoverable error (OAuth rejected,
            persistent 5xx, etc.). One `carrier_transport_log` row is
            written with the failure outcome regardless.
        '503':
          description: Carrier ingestion not configured (encryption key missing).
  /api/v1/admin/carrier-reconcile/{mfgID}/{carrier}:
    parameters:
      - name: mfgID
        in: path
        required: true
        description: Supply-partner ID to reconcile.
        schema:
          type: string
          format: uuid
      - name: carrier
        in: path
        required: true
        description: |
          Carrier identifier. Both `fedex` and `ups` are accepted — the
          reconciliation lane is symmetric across carriers (both query
          the carrier-side Track-by-reference endpoint per still-open
          order). Unknown carriers return 404.
        schema:
          type: string
          enum:
            - ups
            - fedex
    post:
      tags:
        - Admin
      summary: Manually trigger one carrier-reconciliation cycle for a supply partner
      description: |
        Issue #1143 Phase 5. Runs one carrier-reconciliation cycle for
        the named supply partner: asks the carrier-side Track-by-reference
        endpoint for hits on every still-open order, cross-checks each
        hit against `carrier_event_log`, and flags any unknown hits as
        a discrepancy (a `shipment_imports` row with
        `discrepancy_source='reconciliation'` that surfaces on the
        existing pending-tracking queue page). **API-only — no UI twin.**
        The production code path is the daily cron in
        server (fires at the configured reconcile hour,
        UTC, default 02). This endpoint exists for ops debugging and the
        e2e harness.

        `system_admin` only. Returns 503 when carrier ingestion is not
        configured. Returns 404 for unknown carriers.
      security:
        - BearerAuth: []
      responses:
        '200':
          description: >-
            Reconciliation cycle completed (may have flagged zero or many
            discrepancies).
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                  manufacturer_id:
                    type: string
                    format: uuid
                  carrier:
                    type: string
                    enum:
                      - ups
                      - fedex
                  records_fetched:
                    type: integer
                  discrepancies_found:
                    type: integer
        '400':
          description: mfgID missing or malformed.
        '404':
          description: Unknown carrier.
        '502':
          description: |
            Carrier API returned an unrecoverable error (auth rejected,
            persistent 5xx, etc.). A `carrier_transport_log` row is
            written with the failure outcome.
        '503':
          description: Carrier ingestion not configured (encryption key missing).
  /api/v1/admin/carrier-observability:
    get:
      tags:
        - Admin
      summary: >-
        Carrier ingestion health and reconciliation figures (all supply
        partners)
      description: |
        Issue #1143 Phase 6. JSON twin of the read-only carrier-health
        dashboard at `/admin/carriers/health`. Returns per-(supply
        partner, carrier) ingestion figures plus cross-carrier top-line
        aggregates and the recent-events / reconciliation-discrepancy
        ledgers.

        Surfaces the AC's five failure-visibility figures per carrier:
        last-event-received timestamp, events received in the last hour,
        FedEx signature-rejection count, UPS poll-error count, and
        reconciliation discrepancies (last 7 days).

        **`system_admin` only.** This endpoint reports across every
        tenant, so it is restricted to `system_admin`; a
        manufacturer-scoped API key cannot reach it. Read-only. Returns
        503 when carrier ingestion is not configured (encryption key
        missing).
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Carrier observability snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_events_last_hour:
                    type: integer
                  total_signature_rejections_last_hour:
                    type: integer
                  total_poll_errors_last_hour:
                    type: integer
                  total_reconciliation_discrepancies_last_7d:
                    type: integer
                  total_pending_events:
                    type: integer
                  carriers:
                    type: array
                    items:
                      type: object
                      properties:
                        manufacturer_id:
                          type: string
                          format: uuid
                        manufacturer_code:
                          type: string
                        manufacturer_name:
                          type: string
                        carrier:
                          type: string
                          enum:
                            - ups
                            - fedex
                        configured:
                          type: boolean
                        paused:
                          type: boolean
                        last_event_received_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Omitted when no event has ever been received.
                        events_last_hour:
                          type: integer
                        signature_rejections_last_hour:
                          type: integer
                          description: >-
                            FedEx HMAC failures in the last hour (always 0 for
                            UPS).
                        poll_errors_last_hour:
                          type: integer
                          description: >-
                            UPS fetch/oauth/partial-failure outcomes in the last
                            hour (always 0 for FedEx).
                        reconciliation_discrepancies_last_7d:
                          type: integer
                        pending_events:
                          type: integer
                          description: >-
                            carrier_event_log rows stuck at 'pending' (sweeper
                            backlog indicator).
                        pill:
                          type: string
                          enum:
                            - healthy
                            - stale
                            - failing
                            - paused
                            - not_configured
                          description: >-
                            Derived 5-state status for the (supply partner,
                            carrier) pair.
                  recent_events:
                    type: array
                    items:
                      type: object
                      properties:
                        occurred_at:
                          type: string
                          format: date-time
                        carrier:
                          type: string
                          enum:
                            - ups
                            - fedex
                        transport:
                          type: string
                          description: event or transport-log source (e.g. webhook, poll)
                        outcome:
                          type: string
                        manufacturer_id:
                          type: string
                        manufacturer_name:
                          type: string
                        carrier_event_id:
                          type: string
                        outcome_detail:
                          type: string
                  reconciliation_discrepancies:
                    type: array
                    items:
                      type: object
                      properties:
                        found_at:
                          type: string
                          format: date-time
                        shipment_import_id:
                          type: string
                          format: uuid
                        manufacturer_id:
                          type: string
                        manufacturer_name:
                          type: string
                        filename:
                          type: string
        '401':
          description: Missing or invalid credentials.
        '403':
          description: Caller is not a system_admin.
        '503':
          description: Carrier ingestion not configured (encryption key missing).
