# Job operations & materials

Once a job is won, the office takes it through the workspace's **operational stages** (for example Prepare, Execute, Close out), orders its materials, and confirms supply before the crew arrives. These endpoints read and change that record, the same way the job's Operations view does in TruTec.

Writes are made as the person who created the API key, need permission to edit the schedule (`403` `view_only` otherwise) and operations turned on for the workspace (`403` `Operations not enabled`). Material order and supplier records are office notes: **nothing here contacts a supplier**.

**Revisions.** The job's records are shared by everyone in the office, so each write carries the `revision` you read, and a stale one returns `409` instead of overwriting someone else's change. There are three, all in `GET /v1/jobs/{id}/operations`:

| Revision | Send it with |
|---|---|
| `revision` | `/operations/stage` (and optionally `/operations/handoff`) |
| `material_orders.revision` | the three `material-orders` actions |
| `supply_confirmations.revision` | `supply-confirmations` confirm and undo |

On `409`, read the job again and retry with the new revision.

## Get a job's operations status

`GET /v1/jobs/{id}/operations`

**Permission:** Read jobs

```json
{
  "data": {
    "job_id": 70412,
    "job": { "id": 70412, "name": "Northgate Plaza reseal" },
    "stage": "prep",
    "stage_label": "Prepare",
    "revision": 1,
    "handed_off_at": "2026-10-20T15:44:02Z",
    "stage_entered_at": "2026-10-20T15:44:02Z",
    "sales_status": "won",
    "handoff_sales_status": "won",
    "can_hand_off": false,
    "stages": [
      { "key": "prep", "label": "Prepare", "color": "#f59e0b" },
      { "key": "execution", "label": "Execute", "color": "#3b82f6" },
      { "key": "closeout", "label": "Close out", "color": "#10b981" }
    ],
    "material_orders": {
      "revision": 2,
      "estimate_name": "Northgate reseal",
      "total": 1,
      "fully_ordered": 1,
      "received": 0,
      "complete": true,
      "items": [
        {
          "key": "4f0c2a9e1b7d3c6a8e5f1a2b",
          "name": "Sealer",
          "unit": "gal",
          "quantity": 1200.0,
          "status": "ordered",
          "status_label": "Ordered",
          "supplier": "Local supplier",
          "po": "PO-1042",
          "expected_on": "2026-11-02",
          "ordered_quantity": 1200.0,
          "received_quantity": 0.0,
          "fully_ordered": true,
          "estimate_changed": false,
          "special_load_out": false,
          "scope": "9b1e0c4d2a7f5e3c1b8d6a0e",
          "availability_scope": "availability-v1:…",
          "availability": { "state": "confirmed", "label": "Supplier availability confirmed", "supplier": "Local supplier", "fulfillment": "delivery", "confirmed_on": "2026-11-02", "confirmed_time": "07:00", "available_quantity": 1200.0 },
          "updated_by": "Office Manager",
          "updated_at": "2026-10-28T16:02:11Z"
        }
      ]
    },
    "supply_confirmations": {
      "revision": 0,
      "total": 1,
      "open": 1,
      "overdue": 0,
      "due_today": 0,
      "items": [
        {
          "key": "c71d5e0a9b3f2e8d4a6c1b0f",
          "kind": "material",
          "name": "Hot mix asphalt",
          "unit": "ton",
          "quantity": 180.0,
          "lead_days": 2,
          "work_date": "2026-11-09",
          "due_date": "2026-11-05",
          "due_text": "2 business days before the work on Mon, Nov 9",
          "status": "upcoming",
          "status_label": "Upcoming",
          "recheck": null,
          "confirmed_by": null,
          "confirmed_at": null,
          "note": null
        }
      ]
    }
  }
}
```

**Materials** come from the job's awarded estimate (the latest saved one before it is awarded); materials the catalog marks as not purchased, or below their order-above quantity, are left out. `estimate_changed` means the estimate's quantity changed after the order was recorded, so the item counts as not ordered until you record it again. `unit_price` appears in `availability` only for keys whose creator can see prices.

**Supply confirmations** are the catalog items set to "confirm with the supplier N business days before the work". `work_date` is the first scheduled day of the work that uses the item, `due_date` the day the confirmation is due. A confirmation goes back to open (with `recheck` saying why) when the work moves or the quantity changes.

## Stages

### Hand a won job to operations

`POST /v1/jobs/{id}/operations/handoff`

**Permission:** Write jobs

Like **Start operations workflow** in TruTec: the job enters the first operational stage and that stage's automations run (tasks, checklists and in-app notices the owner set up). The job must be in the sales stage set for handoff (`handoff_sales_status`), otherwise `409`. A job already in operations comes back unchanged. Takes an optional `revision`. Returns the operations status.

### Move a job to another stage

`POST /v1/jobs/{id}/operations/stage` with `{"stage": "execution", "revision": 1}`

**Permission:** Write jobs

`stage` is a key from `stages`. That stage's automations run once. Returns the operations status.

## Material orders

The three actions take the material's `key` in the path and `material_orders.revision` in the body. You may also send the item's `scope` as you read it; if the estimate changed since, the response is `409`.

### Record an order

`POST /v1/jobs/{id}/material-orders/{key}/order-status`

**Permission:** Write jobs

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | string | Yes | `unconfirmed`, `partially_ordered`, `ordered`, `partially_received` or `received`. |
| `supplier` | string | Unless `unconfirmed` | Up to 200 characters. |
| `po` | string | No | Purchase order or confirmation number. |
| `expected_on` | string | No | Expected delivery date. |
| `ordered_quantity` | number | For partial orders | Total ordered so far. Defaults to the full quantity. |
| `received_quantity` | number | For partial deliveries | Total received so far. |
| `revision` | integer | Yes | `material_orders.revision`. |

The quantities must agree with the status (for example `ordered` means the full quantity is ordered and none received); otherwise `400` says what's wrong.

### Record a supplier availability check

`POST /v1/jobs/{id}/material-orders/{key}/availability`

**Permission:** Write jobs

| Field | Type | Required | Description |
|---|---|---|---|
| `availability_state` | string | Yes | `not_requested`, `pending`, `confirmed` or `unavailable`. |
| `supplier` | string | Unless `not_requested` | |
| `contact`, `notes` | string | No | Who you spoke to, and anything worth keeping. |
| `fulfillment` | string | No | `delivery` (default) or `pickup`. |
| `requested_on`, `requested_time` | string | No | When you need it. |
| `confirmed_on`, `confirmed_time`, `available_quantity` | | When `confirmed` | What the supplier confirmed. |
| `unit_price` | number | No | Dollars. Needs a key whose creator can see prices (`403` otherwise). |
| `revision` | integer | Yes | `material_orders.revision`. |

Order quantities and status don't change.

### Record what it actually cost

`POST /v1/jobs/{id}/material-orders/{key}/actual-cost`

**Permission:** Write jobs

| Field | Type | Required | Description |
|---|---|---|---|
| `actual_cost` | number | Yes | Dollars, up to 2 decimals. |
| `incurred_on` | string | Yes | Date on the invoice or ticket. |
| `cost_reference` | string | Yes | The supplier invoice or delivery-ticket number. |
| `revision` | integer | Yes | `material_orders.revision`. |

Adds a material cost to the job's costing, pending review, and returns its `cost_entry_id` with the operations status. The same material and reference twice is `409`. Needs estimating and a key whose creator can see prices.

## Supply confirmations

`POST /v1/jobs/{id}/supply-confirmations/{key}/confirm` with `{"revision": 0, "note": "Plant confirmed 180 t for 7am"}`

`POST /v1/jobs/{id}/supply-confirmations/{key}/undo` with `{"revision": 1}`

**Permission:** Write jobs

Confirm records that you confirmed the item with the supplier for its current work date and quantity (a note up to 300 characters is optional). Undo puts it back to open. Both take `supply_confirmations.revision`; if the work date or quantity changed since you read it, the response is `409`.

## Operations Pipeline

The Operations Pipeline board shows every visit (and won work waiting to be scheduled) as a card in a column: **Ready to schedule**, **Scheduled**, **Crew Dispatched**, **In progress**, **Completed** and **Invoiced**, plus any columns the owner added between them.

### List the columns

`GET /v1/operations-pipeline/columns`

**Permission:** Read jobs

```json
{
  "data": [
    { "key": "ready", "label": "Ready to schedule", "color": "#94a3b8", "custom": false, "can_drop_here": true, "moves_here_when": "A job is won on the Sales Pipeline." },
    { "key": "c_1a2b3c4d", "label": "Waiting on permit", "color": "#64748b", "custom": true, "can_drop_here": true, "moves_here_when": "You drag a card here." },
    { "key": "scheduled", "label": "Scheduled", "color": "#60a5fa", "custom": false, "can_drop_here": true, "moves_here_when": "A service is put on the Schedule." }
  ]
}
```

### Move a card

`POST /v1/operations-pipeline/move`

**Permission:** Write jobs

Like dragging a card on the board.

| Field | Type | Required | Description |
|---|---|---|---|
| `work_order_id` | integer | One of these | A visit. |
| `queue_id` | string | One of these | Won work not yet scheduled, from `GET /v1/schedule/queue` (`"es-5512"`, `"job-70412"`). |
| `to` | string | Yes | A column key. |
| `scheduled_date` | string | To `scheduled` | The day. |
| `crew_id` | integer or null | No | With `scheduled`: the crew. |

What each move does:

- **One of the owner's columns:** the card is parked there and that column's automations run once for the job. Nothing on the schedule changes.
- **`ready`:** unschedules the visit (or moves a parked card back).
- **`scheduled`:** puts it on `scheduled_date` (draft until published). Queued work becomes a visit.
- **`done`:** completes the visit.
- **`sent`** is refused (`400`): moving a card to Crew Dispatched sends it to the crew, so use [send-dispatch](customer-notices-and-dispatch.md#send-a-visits-dispatch) instead. `in_progress` and `invoiced` move by themselves (the crew clocks in; an invoice is created).

Moves the board doesn't allow return `400` saying why. Returns the card's `column`, `parked_from` (for an owner's column, the column its events put it in) and the `work_order`, if there is one.
