# Orange Wine — API Part 07: Inventory

Version 1.0 — 28 Sep 2026 · 18 endpoints · Base URL `/api/v1` · Read with Part 00 (conventions, error catalog, permissions).

## 1. Before you start

- Base URL `/api/v1` (webhooks: `/webhooks/...`). JSON, `snake_case` keys, prefixed string IDs, ISO 8601 UTC timestamps.
- Success: `{ data, request_id, correlation_id }`. Error: `{ error: { code, message, details, retryable } }`. Gated feature: `data.enabled = false`, `status = "pending_configuration"`.
- Auth via HttpOnly cookies (`access_token`, `refresh_token`); every non-GET browser request sends `X-XSRF-TOKEN`.
- Money is always `{ amount_minor, currency }`. Percentages are basis points.
- Commands marked Idempotency = Required need an `Idempotency-Key` header (UUID v4).
- Status never changes through PATCH — use the named command endpoints.
- Full conventions, error catalog, permission catalog and open items: Part 00.

### Error codes used in this part

| Code | HTTP | Meaning |
|---|---|---|
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |
| `IDEMPOTENCY_KEY_REUSED` | 409 | Same `Idempotency-Key` sent with a different request body. |
| `INVALID_STATE_TRANSITION` | 409 | Named command not allowed from the resource's current state. |
| `INVENTORY_CONFLICT` | 409 | Requested quantity no longer available at the fulfilling location. |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

### Permissions used in this part

| Permission | Grants | Endpoints |
|---|---|---|
| `inventory.adjust` | Adjustments and counts (manager approval above threshold) | 4 |
| `inventory.receive` | Receive purchase orders, transfers and pre-arrival releases | 3 |
| `inventory.transfer` | Create/ship transfers | 4 |
| `inventory.view` | Read balances, movements, reservations, allocations | 5 |

### Endpoints requiring Idempotency-Key

| Method | Path | Title |
|---|---|---|
| POST | `/dashboard/inventory/exceptions/{exceptionId}/resolve` | Resolve exception |
| POST | `/dashboard/inventory/adjustments` | Adjust stock |
| POST | `/dashboard/inventory/counts/{countId}/submit` | Submit count |
| POST | `/dashboard/purchase-orders/{purchaseOrderId}/receive` | Receive PO |
| POST | `/dashboard/transfers/{transferId}/approve` | Approve transfer |
| POST | `/dashboard/transfers/{transferId}/ship` | Ship transfer |
| POST | `/dashboard/transfers/{transferId}/receive` | Receive transfer |

### PROPOSED and gated endpoints in this part

| Status | Method | Path |
|---|---|---|
| PROPOSED | GET | `/dashboard/inventory/exceptions` |
| PROPOSED | POST | `/dashboard/inventory/exceptions/{exceptionId}/resolve` |
| PROPOSED | POST | `/dashboard/transfers/{transferId}/approve` |

## 2. Inventory

Ledger-backed inventory per product × location (D-10). No endpoint accepts a balance value; every change is a movement.

| # | Method | Path | Title | Status |
|---|---|---|---|---|
| 2.1 | GET | `/inventory/products/{productId}` | Public availability | CONFIRMED |
| 2.2 | GET | `/dashboard/inventory` | Balances | CONFIRMED |
| 2.3 | GET | `/dashboard/inventory/movements` | Movement ledger | CONFIRMED |
| 2.4 | GET | `/dashboard/inventory/reservations` | Active reservations | CONFIRMED |
| 2.5 | GET | `/dashboard/inventory/allocations` | Allocations | CONFIRMED |
| 2.6 | GET | `/dashboard/inventory/exceptions` | Inventory exceptions | PROPOSED |
| 2.7 | POST | `/dashboard/inventory/exceptions/{exceptionId}/resolve` | Resolve exception | PROPOSED |
| 2.8 | POST | `/dashboard/inventory/adjustments` | Adjust stock | CONFIRMED |
| 2.9 | POST | `/dashboard/inventory/counts` | Start stock count | CONFIRMED |
| 2.10 | POST | `/dashboard/inventory/counts/{countId}/submit` | Submit count | CONFIRMED |
| 2.11 | GET | `/dashboard/purchase-orders` | Purchase orders | CONFIRMED |
| 2.12 | POST | `/dashboard/purchase-orders` | Create purchase order | CONFIRMED |
| 2.13 | POST | `/dashboard/purchase-orders/{purchaseOrderId}/receive` | Receive PO | CONFIRMED |
| 2.14 | GET | `/dashboard/transfers` | Transfers | CONFIRMED |
| 2.15 | POST | `/dashboard/transfers` | Create transfer | CONFIRMED |
| 2.16 | POST | `/dashboard/transfers/{transferId}/approve` | Approve transfer | PROPOSED |
| 2.17 | POST | `/dashboard/transfers/{transferId}/ship` | Ship transfer | CONFIRMED |
| 2.18 | POST | `/dashboard/transfers/{transferId}/receive` | Receive transfer | CONFIRMED |

### 2.1 `GET /inventory/products/{productId}`

**Public availability**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/inventory/products/{productId}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| productId | string |  |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · optional |  |

**Request example**

```http
GET /api/v1/inventory/products/{productId} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "product_id": "prod_101",
    "location_id": "loc_albany",
    "available": true,
    "state": "available_now"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| state | enum | `available_now` \| `pre_arrival` \| `backordered` \| `unavailable`. Never an exact quantity. |

---

### 2.2 `GET /dashboard/inventory`

**Balances**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/inventory` |
| Auth | Staff `inventory.view` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · optional |  |
| product_id | string · optional |  |
| q | string · optional | SKU/name/barcode. |
| low_stock | boolean · optional |  |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/inventory?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "location_id": "loc_albany",
        "on_hand": 24,
        "reserved": 2,
        "allocated": 5,
        "available": 17,
        "damaged": 1,
        "in_transit": 0,
        "updated_at": "2026-09-17T10:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| on_hand | integer ≥ 0 | Physically present, sellable. |
| reserved | integer ≥ 0 | Checkout holds. |
| allocated | integer ≥ 0 | Committed to paid orders, not yet picked. |
| available | integer | `on_hand − reserved − allocated` (computed; never writable). |
| damaged | integer ≥ 0 | Not sellable, excluded from available. |
| in_transit | integer ≥ 0 | Transfers / inbound POs not yet received. |
| pagination.page | integer | Current page. |
| pagination.per_page | integer | Page size used. |
| pagination.total | integer | Total matching items. |
| pagination.last_page | integer | Last page number; 0 when there are no items. |

---

### 2.3 `GET /dashboard/inventory/movements`

**Movement ledger**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/inventory/movements` |
| Auth | Staff `inventory.view` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id / product_id | string · optional |  |
| type | enum · optional |  |
| from / to | date · optional |  |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/inventory/movements?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "mov_2001",
        "product_id": "prod_101",
        "location_id": "loc_albany",
        "type": "receive",
        "component": "on_hand",
        "quantity_delta": 24,
        "reason": "PO received",
        "source_type": "purchase_order",
        "source_id": "po_77",
        "actor": {
          "id": "staff_05",
          "type": "staff"
        },
        "approval_id": null,
        "created_at": "2026-09-16T09:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| type | enum | `receive` \| `adjust` \| `damage` \| `shrinkage` \| `count_variance` \| `transfer_out` \| `transfer_in` \| `reserve` \| `reserve_release` \| `reserve_expire` \| `allocate` \| `allocation_release` \| `pick_consume` \| `pos_sale` \| `return_restock` \| `return_damaged` \| `pre_arrival_receive` \| `exception`. |
| component | enum | `on_hand` \| `reserved` \| `allocated` \| `damaged` \| `in_transit`. |
| quantity_delta | integer | Signed. |
| source_type | enum | `purchase_order` \| `transfer` \| `count` \| `adjustment` \| `order` \| `checkout_session` \| `pos_sale` \| `return` \| `pre_arrival` \| `fulfillment`. |

---

### 2.4 `GET /dashboard/inventory/reservations`

**Active reservations**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/inventory/reservations` |
| Auth | Staff `inventory.view` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id / product_id | string · optional |  |
| status | enum · optional | `active` \| `committed` \| `released` \| `expired`. |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/inventory/reservations?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "res_7001",
        "checkout_session_id": "chk_9001",
        "product_id": "prod_101",
        "quantity": 2,
        "status": "active",
        "expires_at": "2026-09-17T12:30:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.5 `GET /dashboard/inventory/allocations`

**Allocations**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/inventory/allocations` |
| Auth | Staff `inventory.view` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id / product_id / order_id | string · optional |  |
| status | enum · optional |  |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/inventory/allocations?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "alloc_001",
        "order_id": "ord_5001",
        "fulfillment_group_id": "ful_9001",
        "product_id": "prod_101",
        "quantity": 2,
        "source": "payment_capture",
        "status": "active",
        "created_at": "2026-09-17T16:00:05Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| source | enum | `payment_capture` \| `pre_arrival_release` \| `operational`. |
| status | enum | `active` \| `consumed` \| `released` \| `exception`. |

---

### 2.6 `GET /dashboard/inventory/exceptions`

**Inventory exceptions**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/inventory/exceptions` |
| Auth | Staff `inventory.view` |
| Status | PROPOSED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum · optional | `open` \| `resolved`. |
| type | enum · optional |  |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/inventory/exceptions?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "iex_55",
        "type": "inventory_discrepancy",
        "product_id": "prod_101",
        "location_id": "loc_albany",
        "fulfillment_id": "ful_9001",
        "expected_quantity": 2,
        "found_quantity": 1,
        "status": "open",
        "created_at": "2026-09-17T18:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| type | enum | `inventory_discrepancy` \| `pre_arrival_shortfall` \| `other`. |

---

### 2.7 `POST /dashboard/inventory/exceptions/{exceptionId}/resolve`

**Resolve exception**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/inventory/exceptions/{exceptionId}/resolve` |
| Auth | Staff `inventory.adjust` |
| Status | PROPOSED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| exceptionId | string |  |

**Request example**

```http
POST /api/v1/dashboard/inventory/exceptions/{exceptionId}/resolve HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "resolution": "adjustment",
  "adjustment_id": "adj_401",
  "notes": "Bottle found broken in back room."
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| resolution | enum · required | `count` \| `adjustment` \| `escalation`. |
| adjustment_id / count_id | string · optional | Linked record. |
| notes | string · required |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "iex_55",
    "status": "resolved",
    "resolution": "adjustment"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Already resolved |

---

### 2.8 `POST /dashboard/inventory/adjustments`

**Adjust stock**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/inventory/adjustments` |
| Auth | Staff `inventory.adjust` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Request example**

```http
POST /api/v1/dashboard/inventory/adjustments HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "product_id": "prod_101",
  "location_id": "loc_albany",
  "quantity_delta": -1,
  "reason": "damaged_stock",
  "notes": "Broken during shelving.",
  "manager_approval_id": "approval_1002"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| product_id / location_id | string · required |  |
| quantity_delta | integer · required · ≠ 0 | Signed change to on_hand. |
| reason | enum · required | `damaged_stock` \| `shrinkage` \| `found_stock` \| `correction` \| `sample` \| `other`. |
| notes | string · required |  |
| manager_approval_id | string · required above threshold | Threshold configurable. |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "adjustment_id": "adj_401",
    "movement_id": "mov_3001",
    "balance": {
      "product_id": "prod_101",
      "sku": "CAB-2023-750",
      "location_id": "loc_albany",
      "on_hand": 23,
      "reserved": 2,
      "allocated": 5,
      "available": 16,
      "damaged": 1,
      "in_transit": 0,
      "updated_at": "2026-09-17T10:00:00Z"
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| on_hand | integer ≥ 0 | Physically present, sellable. |
| reserved | integer ≥ 0 | Checkout holds. |
| allocated | integer ≥ 0 | Committed to paid orders, not yet picked. |
| available | integer | `on_hand − reserved − allocated` (computed; never writable). |
| damaged | integer ≥ 0 | Not sellable, excluded from available. |
| in_transit | integer ≥ 0 | Transfers / inbound POs not yet received. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVENTORY_CONFLICT` | 409 | Would break reserved + allocated ≤ on_hand |
| `FORBIDDEN` | 403 | Missing permission or required manager approval |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |
| `IDEMPOTENCY_KEY_REUSED` | 409 | Same `Idempotency-Key` sent with a different request body. |

---

### 2.9 `POST /dashboard/inventory/counts`

**Start stock count**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/inventory/counts` |
| Auth | Staff `inventory.adjust` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/dashboard/inventory/counts HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "location_id": "loc_albany",
  "scope": {
    "department_id": "dept_wine"
  }
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · required |  |
| scope | object · optional | `{ department_id \| category_id \| product_ids[] }`; omit for full count. |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "count_id": "cnt_12",
    "status": "open",
    "lines_expected": 340
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | `open` \| `submitted` \| `approved` \| `posted`. |

---

### 2.10 `POST /dashboard/inventory/counts/{countId}/submit`

**Submit count**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/inventory/counts/{countId}/submit` |
| Auth | Staff `inventory.adjust` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| countId | string |  |

**Request example**

```http
POST /api/v1/dashboard/inventory/counts/{countId}/submit HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "lines": [
    {
      "product_id": "prod_101",
      "counted_quantity": 22
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| lines[].product_id | string · required |  |
| lines[].counted_quantity | integer ≥ 0 · required |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "count_id": "cnt_12",
    "status": "submitted",
    "variances": [
      {
        "product_id": "prod_101",
        "system_quantity": 24,
        "counted_quantity": 22,
        "variance": -2
      }
    ],
    "approval_required": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| approval_required | boolean | Manager approval needed to post variances. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Not open |

---

### 2.11 `GET /dashboard/purchase-orders`

**Purchase orders**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/purchase-orders` |
| Auth | Staff `inventory.receive` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum · optional | `draft` \| `submitted` \| `partially_received` \| `received` \| `cancelled`. |
| location_id | string · optional |  |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/purchase-orders?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "po_77",
        "vendor": "Example Distributor",
        "location_id": "loc_albany",
        "status": "submitted",
        "expected_at": "2026-09-20",
        "total_cost": {
          "amount_minor": 120000,
          "currency": "USD"
        }
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.12 `POST /dashboard/purchase-orders`

**Create purchase order**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/purchase-orders` |
| Auth | Staff `inventory.receive` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/dashboard/purchase-orders HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "vendor_id": "ven_3",
  "location_id": "loc_albany",
  "expected_at": "2026-09-20",
  "lines": [
    {
      "product_id": "prod_101",
      "quantity": 24,
      "unit_cost": {
        "amount_minor": 3500,
        "currency": "USD"
      }
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| vendor_id | string · required |  |
| location_id | string · required |  |
| expected_at | date · optional |  |
| lines[] | object[] · required | `{ product_id, quantity, unit_cost }`. |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "id": "po_78",
    "status": "draft"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.13 `POST /dashboard/purchase-orders/{purchaseOrderId}/receive`

**Receive PO**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/purchase-orders/{purchaseOrderId}/receive` |
| Auth | Staff `inventory.receive` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| purchaseOrderId | string |  |

**Request example**

```http
POST /api/v1/dashboard/purchase-orders/{purchaseOrderId}/receive HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "lines": [
    {
      "product_id": "prod_101",
      "received_quantity": 23,
      "damaged_quantity": 1
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| lines[].received_quantity | integer · required | Adds to on_hand. |
| lines[].damaged_quantity | integer · optional | Adds to damaged. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "po_77",
    "status": "received",
    "movement_ids": [
      "mov_2001",
      "mov_2002"
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Named command not allowed from the resource's current state. |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

---

### 2.14 `GET /dashboard/transfers`

**Transfers**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/transfers` |
| Auth | Staff `inventory.transfer` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum · optional | `draft` \| `approved` \| `shipped` \| `in_transit` \| `received`. |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/transfers?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "trf_9",
        "source_location_id": "loc_albany",
        "destination_location_id": "loc_saratoga",
        "status": "draft",
        "lines": [
          {
            "product_id": "prod_101",
            "quantity": 6
          }
        ]
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.15 `POST /dashboard/transfers`

**Create transfer**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/transfers` |
| Auth | Staff `inventory.transfer` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/dashboard/transfers HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "source_location_id": "loc_albany",
  "destination_location_id": "loc_saratoga",
  "lines": [
    {
      "product_id": "prod_101",
      "quantity": 6
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| source_location_id / destination_location_id | string · required | Must differ; caller needs scope on source. |
| lines[] | object[] · required |  |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "id": "trf_9",
    "status": "draft"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.16 `POST /dashboard/transfers/{transferId}/approve`

**Approve transfer**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/transfers/{transferId}/approve` |
| Auth | Manager |
| Status | PROPOSED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| transferId | string |  |

**Request example**

```http
POST /api/v1/dashboard/transfers/{transferId}/approve HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "trf_9",
    "status": "approved"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Named command not allowed from the resource's current state. |

---

### 2.17 `POST /dashboard/transfers/{transferId}/ship`

**Ship transfer**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/transfers/{transferId}/ship` |
| Auth | Staff `inventory.transfer` (source scope) |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| transferId | string |  |

**Request example**

```http
POST /api/v1/dashboard/transfers/{transferId}/ship HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "trf_9",
    "status": "in_transit",
    "movement_ids": [
      "mov_4001"
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | Source on_hand ↓, destination in_transit ↑. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVENTORY_CONFLICT` | 409 | Not enough available at source |
| `FORBIDDEN` | 403 | No source scope |

---

### 2.18 `POST /dashboard/transfers/{transferId}/receive`

**Receive transfer**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/transfers/{transferId}/receive` |
| Auth | Staff `inventory.transfer` (destination scope) |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| transferId | string |  |

**Request example**

```http
POST /api/v1/dashboard/transfers/{transferId}/receive HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "lines": [
    {
      "product_id": "prod_101",
      "received_quantity": 6
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| lines[].received_quantity | integer · required |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "trf_9",
    "status": "received",
    "movement_ids": [
      "mov_4002"
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN` | 403 | No destination scope |
| `INVALID_STATE_TRANSITION` | 409 | Named command not allowed from the resource's current state. |

---
