# Production work orders

A production work order is the one-page sheet the office hands crews after winning a job: the job and site header (customer, address, gate code, contacts, permit, flags such as certified payroll), one scope row per service with the production quantities its trade needs (paving: square feet, thickness, tons, rock tons, mix, tack; sealcoat: square feet, gallons, application, sealer; milling: square feet, depth, tons), and the crew plan (crew, days, hours per day, trucks, an alternate crew).

It is generated from an estimate and kept in step with it: **refresh** re-derives the rows from the estimate while keeping anything someone typed by hand.

These are not the crew schedule's visits. Those are [`/v1/work-orders`](schedule-and-crews.md). When scheduling is on, a new sheet's rows go to the schedule's "waiting to schedule" queue by themselves (no crew is notified until the schedule is published), and each row's `schedule_work_order_ids` points at its visits.

Writes act as the user who created the API key, with that user's estimating role. Nothing on this page emails or texts anyone.

!!! note
    Partner keys that act on behalf of several workspaces must also pass `firm_id` (query string, or JSON body on writes).

## The production work order object

| Field | Type | Description |
|---|---|---|
| `id` | integer | |
| `number` | integer or null | The sheet number. |
| `job_id`, `estimate_id` | integer | |
| `status` | string | `issued` or `done`. |
| `customer_name`, `job_name`, `job_address`, `sales_rep`, `project_manager` | string or null | Header. |
| `issued_at`, `generated_at`, `created_at`, `updated_at` | string or null | `generated_at` is when the rows were last derived from the estimate. |
| `contacts` | array | Up to two: `name`, `email`, `phone`, `cell`. Single sheet only, like the fields below. |
| `gate_code`, `summary`, `location_type`, `permit_number`, `directions_url`, `notes` | string or null | |
| `flags` | object | `certified_payroll`, `osha_certified`, `badging`, `drone`, `nto` (true/false). |
| `is_subcontracted`, `sub_name`, `sub_phone` | | When the work goes to a subcontractor. |
| `estimate_changed_since_generated` | boolean | The estimate changed after the rows were derived; a refresh would pick it up. |
| `totals` | object | `tons`, `rock_tons`, `gallons`, `crew_days`, `sqft`. |
| `lines` | array | The scope rows (below). |

**A row:** `id`, `line_item_id` (the estimate line), `position`, `trade` (`paving`, `sealcoat`, `milling`, `striping`, `concrete`, `generic`), `label`, `quantities` (by name), `edited_quantities` (names typed by hand, kept on refresh), `crew_id`, `days`, `hours_per_day`, `trucks`, `alt_crew_id`, `alt_days`, `alt_hours_per_day`, `notes`, `schedule_work_order_ids`.

## Read

`GET /v1/production-work-orders` lists sheets, newest first. Filters: `job_id`, `estimate_id`, `status`, `updated_after` (then oldest change first), `limit`, `offset`. **Permission:** Read jobs.

`GET /v1/production-work-orders/{id}` returns one with its rows and totals.

## Create

`POST /v1/jobs/{job_id}/production-work-orders`

**Permission:** Write jobs (role: owner, admin, estimator or sales)

Generates a sheet from one of the job's estimates.

| Name | Type | Description |
|---|---|---|
| `estimate_id` | integer | Default: the job's newest estimate. |
| `line_item_id` | integer | Only this service's work. |
| `crew_id` | integer | Only this crew's work. |

Send neither for every service. Like the app, the new rows go to the crew schedule's queue (when scheduling is on) and the sheet's PDF goes to the job's crew packet. Nobody is notified.

```bash
curl -X POST "https://trutec.ai/api/v1/jobs/70412/production-work-orders" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"estimate_id": 9840}'
```

```json
{
  "data": {
    "id": 615, "number": 1042, "job_id": 70412, "estimate_id": 9840, "status": "issued",
    "customer_name": "Northgate Properties", "job_name": "Northgate Plaza", "job_address": "4100 Commerce Dr",
    "sales_rep": null, "project_manager": null,
    "issued_at": null, "generated_at": "2026-10-13T19:02:51Z",
    "created_at": "2026-10-13T19:02:51Z", "updated_at": "2026-10-13T19:02:51Z",
    "contacts": [], "gate_code": null, "summary": null, "location_type": null,
    "flags": {"certified_payroll": false, "osha_certified": false, "badging": false, "drone": false, "nto": false},
    "permit_number": null, "is_subcontracted": false, "sub_name": null, "sub_phone": null,
    "directions_url": null, "notes": null, "estimate_changed_since_generated": false,
    "totals": {"tons": 0.0, "rock_tons": 0.0, "gallons": 1450.0, "crew_days": 2.0, "sqft": 51000.0},
    "lines": [
      {"id": 3301, "line_item_id": 140261, "position": 0, "trade": "sealcoat", "label": "Sealcoat",
       "quantities": {"sqft": 51000, "raw_gallons": 1450, "application": "2 coats"},
       "edited_quantities": [], "crew_id": 12, "days": 2.0, "hours_per_day": 8.0, "trucks": null,
       "alt_crew_id": null, "alt_days": null, "alt_hours_per_day": null, "notes": null,
       "schedule_work_order_ids": [88213]}
    ]
  }
}
```

**Errors:** `400` `Build the estimate before creating a work order.`, `That service has no work on this estimate.` `404` `Job not found`, `Estimate not found`, `Crew not found`.

## Change the header

`PATCH /v1/production-work-orders/{id}`

**Permission:** Write jobs (role: owner, admin, estimator or sales)

Only the fields you send change: any header text field above, `is_subcontracted`, `flags` (any of the five), `contacts` (replaces the list). The crew-packet PDF refreshes.

## Refresh, issue, done

`POST /v1/production-work-orders/{id}/refresh` re-derives the rows from the estimate. Quantities and crew plans typed by hand are kept; rows for services added since are added and go to the schedule queue. The response adds `refresh` with what changed. `409` when the sheet isn't linked to an estimate.

`POST /v1/production-work-orders/{id}/issue` marks it issued (stamping `issued_at` the first time). `POST /v1/production-work-orders/{id}/mark-done` marks it done.

## Rows

`POST /v1/production-work-orders/{id}/lines` adds a row (`label` required). `PATCH /v1/production-work-orders/{id}/lines/{line_id}` changes one. Both take `label`, `trade`, `crew_id`, `days`, `hours_per_day`, `trucks`, `alt_crew_id`, `alt_days`, `alt_hours_per_day`, `notes`, `quantities` (merged by name; names you set are kept through refreshes) and `reset_quantities` (names to hand back to the estimate on the next refresh). They return `data` (the row) and the sheet's `totals`. A workspace with "Scope changes only in the estimate" on refuses new rows (`403`); change the estimate and refresh instead. Existing rows stay editable.

`DELETE /v1/production-work-orders/{id}/lines/{line_id}` removes a row (`204`).

**Permission:** Write jobs (role: owner, admin, estimator or sales).

## Crew packet

`POST /v1/production-work-orders/{id}/attach-to-dispatch` renders the sheet as a PDF and saves it as the job's crew-packet document, replacing the earlier copy. Crews see it in the field app. Returns `document_id`; `503` when PDF rendering is unavailable right now.

## Delete

`DELETE /v1/production-work-orders/{id}`

**Permission:** Write jobs and Delete records (role: owner, admin or estimator)

Deletes the sheet. Visits already on the crew schedule stay. Returns `204`.
