# GENERATED — do not edit. Copied from the app repo's openapi.yaml by
# scripts/sync-openapi.mjs (npm run sync-openapi). Edit the app's spec instead.
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

    # ─── Core entity response schemas (#1393) ──────────────────
    # These document the shapes the Go handlers already serialize — wire
    # truth, warts included. No handler behavior is implied beyond what the
    # referenced structs emit today.

    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 #1182; legacy rows may carry free text.
        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, #1416). Actor names are display names; null when the step has not happened.
      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, #1416).
      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}, #1416).
      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:
  # ─── Items ─────────────────────────────────────────────────
  /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' } } }

  # ─── Orders ────────────────────────────────────────────────
  /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 triple (#1200). Anchored on ship_via_id: include it to
                # set the carrier + normalized service/LTL-carrier authoritatively;
                # omit it to preserve the existing values (unlike the COALESCE
                # fields above, where an empty string also preserves).
                # Setting the Will Call carrier forces freight_terms = "WILL CALL" (#1369).
                ship_via_id: { type: string, format: uuid, nullable: true }
                ship_method: { type: string, nullable: true }
                ship_via_other: { type: string, nullable: true }
                # Third-party billing (#1201; applied only when freight_terms = "3RD PARTY").
                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' }

  # ─── Order line CRUD (Phase 3e) ───────────────────
  /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' } } }

  # ─── Direct-ship notify / mark-noted (Phase 3e) ────────────
  /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' } } }

  # ─── Customers ─────────────────────────────────────────────
  /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' }

  # ─── Locations (Phase 3c) ──────────────────────────────────
  /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, quarantine). }
        - { 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' } } }

  # ─── Locations bulk + import (Phase 3d) ────────────────────
  /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 Keys ──────────────────────────────────────────────
  /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' }

  # ─── Supply Partners ───────────────────────────────────────
  /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' }

  # ─── Item Prices (#867) ───────────────────────────────────
  # Pass-through display pricing. The supply partner sets unit prices that
  # ConsignTrak shows read-only on pick tickets, order detail, and the mobile
  # picker. ConsignTrak does NOT invoice — this is pure display. Effective-
  # dated rows; updating means end-dating the prior open row at the new
  # row's effective_from. Consignor-role API keys are blocked at the parent
  # group (consignors use /portal/pricing instead).
  /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' }

  # ─── Documents ─────────────────────────────────────────────
  /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
          # Must match the handler-accepted set (documentParentViewPermission
          # in internal/web/documents.go). 'rma' is intentionally excluded —
          # the handler 400s on it until the RMA feature ships its permission
          # entry. 'short_pick_exception' added in #1204.
          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' }

  # ─── Events (outbound integration stream) ──────────────────
  /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' }

  # ─── Shipment-event surface (issue #868) ────────────────────
  /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' }

  # ─── Webhook subscriptions (issue #868) ─────────────────────
  /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' }

  # ─── Receiving (#724 Phase 2a) ─────────────────────────────────
  /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' }

  # ─── Cycle counts (#724 Phase 2b) ──────────────────────────────
  /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' }

  # ─── Inventory adjustment (#724 Phase 2b) ──────────────────────
  /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' }

  # ─── Exceptions + quarantine (#724 Phase 2b) ───────────────────
  /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' }

  # ─── Shipment tracking: mobile capture + office edit + bulk import (#724 Phase 2b) ──────
  /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 }

  # ─── Pick / pack / ship (#724 Phase 2c) ───────────────────────
  /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.

  # NOTE: POST /api/v1/pick-batches/{batchID}/tracking is defined alongside its
  # PATCH twin in the Shipment-tracking section above — path keys must be unique
  # (YAML is last-key-wins; see #1392).

  /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' }

  # ─── Admin Users (#726 Phase 4) ────────────────────────────
  /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' }

  # ─── Admin Config (#726 Phase 4) ───────────────────────────
  /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' }

  # ─── Account Security (#726 Phase 4) ───────────────────────
  /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' }

  # ─── Carrier Credentials (issue #1143 Phase 2) ─────────────
  /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).
