Materials Dockets
A materials docket is one supplier delivery event — one truck, one drop-off, one supplier docket number. This page covers the supervisor’s create-and-submit flow and the cost-controller’s workbench / diary surfaces.
Per-delivery cardinality
Per Decision #4 in the spec, the cardinality rule is one docket per supplier delivery event, multi-line within when the delivery contains multiple materials. Concrete examples:
- A concrete truck arrives with 12 m³ of N32 plus a Saturday surcharge → one docket, two lines.
- Eight separate deliveries on a Tuesday (3 concrete, 2 reo, 3 misc) → eight dockets.
- A flatbed drops both reo bar and reo mesh from the same supplier on the same delivery slip → one docket with two lines.
The driver is the supplier’s own delivery docket — Lacroo’s delivery_docket_no field always carries the supplier’s reference, and reconciliation joins on that value. Splitting a single supplier docket into multiple Lacroo dockets breaks the join.
Creating a delivery docket
There are three entry points, all of which open the same Add Materials Docket sheet:
- From the workbench:
/admin/dockets?type=materials→ + New Materials Docket. - From the diary’s Materials tab:
/admin/diaries/{diaryId}→ Materials tab → + Add Delivery. Pre-populatesdate,project_id, anddiary_id. - From the supplier’s contract (admin only): the contract editor’s quick action panel exposes a + Test Docket which opens the same sheet pre-populated to the contract’s supplier.
The sheet is composed of three sections:
Header
| Field | Required at submit? | Notes |
|---|---|---|
| Date | Yes | Defaults to today. Date picker. |
| Supplier | Yes | Typeahead, scoped to the project’s link-projects with agreement_type='materials'. |
| Contract | Yes (auto) | Auto-selected from the supplier’s link-project; visible read-only. |
| Primary Supervisor | Yes | Typeahead of the project’s supervisors (see Docket Supervisor). |
| Delivery Docket No. | Yes | Supplier’s reference number; the universal reconciliation join key. |
| Source Location | Yes | Free text — e.g. “Boral Wacol batching plant”. |
| Delivery Location | Yes | Free text — e.g. “BMT Northern Portal, west pour deck”. |
| Notes | No | Free-form textarea. |
start_time and end_time are intentionally left NULL on materials dockets — deliveries are point-in-time events.
The contract and diary must be on the docket’s project, and the supervisor must be a supervisor on it: a member whose role on the project is Supervisor or Senior Supervisor (Docket Supervisor). The sheet sends the whole header on every save, so only a changed one is checked. Saving the supervisor, contract or diary the docket already has is never refused, even if that person has since left the project or that contract was deleted. A delivery added from a diary takes the diary’s supervisor.
Lines
Each line maps to one row in docket_materials. Add via + Add Line. Columns:
- Material — typeahead, searches
name,supplier_sku, anddescriptionsimultaneously. Picker is scoped to the contract’scontract_materials(soft-deleted rows are excluded). Picking a material snapshots itsname,supplier_sku,unit,rate, andminimum_qtyonto the line — these are immutable after that point (Decision #10 b1). - Qty — numeric input, decimal allowed. The supervisor enters what arrived. Required.
- Unit — read-only, populated from the material snapshot.
- Rate — read-only, populated from the material snapshot. Cannot be overridden per line (Decision #10 c1).
- Line Total — read-only, computed
qty × rate.
Adds, quantity edits and removals are held locally and written by one PUT .../dockets/{docketId}/materials when the docket is saved. That request carries
the whole desired line set: a line with an id is kept at the posted quantity
and activity, a line without one is inserted, and any persisted line the request
omits is soft-deleted. The server applies all three inside a single transaction
and reprices once, so a ten-line edit is one round-trip rather than eleven.
Prices are not on the wire. The request carries only material_id,
entered_qty and activity_id; the rate, unit and name snapshots are resolved
server-side from the contract catalogue, so a client cannot write a rate into a
money column. New lines are resolved rate-line-first against the docket’s own
contract — an id that names a material outside that contract is rejected for the
whole batch rather than silently dropped.
A line’s activity_id is its cost code, so it must be a live activity on the
docket’s own project. Create, add, edit and the full save all check every line
in one query. Any other id refuses the whole request with 400 activity-not-on-project and writes nothing. The refusal does not say whether
the activity is on another project, deleted or unknown.
Only a new line or a changed activity is checked. An edit that sends the activity a line already has is never refused, so a line whose cost code was deleted later stays editable.
While the docket’s existing lines are still loading, the line controls and Save stay disabled. Nothing typed into the table can be overwritten by the fetch landing underneath it.
Custom fields
The custom-fields renderer at the bottom of the sheet UNION-fetches definitions for entity_type='docket' (cross-cutting fields) and entity_type='materials_docket' (materials-only fields). This is where per-project admins surface fields like truck_rego, driver_name, slump, batch_no, hazmat_flag — anything specific to materials dockets that isn’t already a structured column.
Custom fields with required=true block submit just like the structured required fields.
The two-row top-up display
This is the spec’s most distinctive UX call (Decision #10 e2 / §4.2 / §6). When the supervisor enters a quantity below the contract’s minimum, the editor renders two rows side-by-side:
| Material | Qty | Unit | Rate | Line Total | Kind |
|---|---|---|---|---|---|
| N32 Concrete | 9.7 | m3 | 285.00 | 2,764.50 | delivered |
| N32 Concrete | 0.3 | m3 | 285.00 | 85.50 | minimum top-up |
Why two rows, not just a corrected quantity? Decision #3:
- The delivered row records what the supervisor actually saw arrive — a fact about the physical world. They sign for it.
- The minimum top-up row records what the supplier is contractually entitled to bill — a fact about the agreement. The contract minimum is 10 m³.
- Both rows price (qty × rate). The total invoice is the sum. The supervisor signs knowing both facts.
- Both rows share
source_entity_id = contract_materials.id, so the cost-allocation resolver routes them to the same PM line. There’s nothing extra to allocate.
The top-up row is read-only — supervisors cannot edit it. It appears the moment entered_qty < minimum_qty_snapshot and disappears the moment entered_qty >= minimum_qty_snapshot.
If the supplier’s rate sheet has no minimum_qty, no top-up row ever appears. That’s the “ordered but not delivered” or “no contract floor” path — one line, priced as entered.
Submitting a docket
Click Submit. The Slice 8 validator enforces:
- Header structured fields:
delivery_docket_no,source_location,delivery_locationall non-empty. - At least one line in
docket_materialswithentered_qty > 0and a snapshot rate. - Custom-field requireds: any
custom_field_definitionwithrequired=trueand matchingentity_type IN ('docket','materials_docket')must have a value present.
If any of these fail, the API returns 400 Bad Request with a field-level error map. The UI surfaces per-field error pills inline; the submit button stays disabled until they’re cleared. Bulk-submit (from the workbench checkbox toolbar) skips invalid dockets and reports "X submitted, Y failed validation" so an admin can drill into the failures.
Lifecycle
Materials dockets follow the standard lifecycle, identical to labour-plant:
draft— supervisor creates it; can edit anything; visible only to themselves and admins.field_user_submitted— a field user sent it in from the mobile app; a supervisor pushes it on tosubmitted, or sends it back to draft.submitted— supervisor clicks Submit, the validator passes; visible to cost-controller / project admin.reviewed— cost-controller checks line totals + allocations.confirmed— final state; locked from edits; eligible for ERP push.
Free-edit-while-draft (Decision #10 d1) means the supervisor can add, remove, or modify lines until submit. Line and header writes are draft-only — every materials write endpoint refuses a docket past draft, so the editor opens read-only from field_user_submitted onward. To change a submitted docket, send it back to draft first.
Where the actions are
The footer of the delivery sheet is the same component every docket type uses, so a material docket offers exactly the actions a labour docket offers at the same status, for the same permissions:
| Status | Actions shown | Permission |
|---|---|---|
draft (or new) | Save Draft · Save & Submit → | dockets.edit |
field_user_submitted | Send back to Draft · Submit → | dockets.review / dockets.supervise |
submitted | Send back to Draft · Mark Reviewed ✓ | dockets.review |
reviewed | Send back to Submitted · Confirm ✓ | dockets.review / dockets.confirm |
confirmed | Unconfirm | dockets.confirm |
A viewer who holds none of those permissions at a given status sees the docket with no actions rather than a button that would only be refused.
Delete follows the same rule as every docket: see Deleting a Docket.
Every transition invalidates the dockets workbench, the diary tab and the docket’s own detail query, so the new status appears everywhere without a page refresh.
Level-by-level revert
Reverse transitions are one-at-a-time, never all-the-way-to-draft. A confirmed docket steps back to reviewed, then submitted, then draft — each click is its own audited action, and each one clears only the audit fields it owns. Unconfirming nulls confirmed_at / confirmed_by but preserves the prior reviewer’s attribution; unreviewing nulls reviewed_at / reviewed_by but preserves the submitter; unsubmitting nulls submitted_at / submitted_by. Each step writes its own audit row (docket.unconfirmed, docket.unreviewed, docket.unsubmitted) parallel to the forward-direction docket.confirmed / docket.reviewed / docket.submitted events, so the full lifecycle is reconstructable from audit_logs alone.
The action is gated by the same role predicate as the corresponding forward step — if you can submit a docket you can unsubmit it, and so on:
| Role | Unsubmit (submitted → draft) | Unreview (reviewed → submitted) | Unconfirm (confirmed → reviewed) |
|---|---|---|---|
supervisor | yes | no | no |
senior_supervisor | yes | no | no |
engineer | yes | yes | no |
project_admin | yes | yes | yes |
organisational_manager | yes | yes | yes |
master_admin | yes | yes | yes |
Unconfirm is a hard isAdmin gate, and isAdmin resolves to master_admin, organisational_manager, or project_admin — the three are treated as a single admin tier across the docket lifecycle. Any docket with last_export_batch_id IS NOT NULL is refused at the SQL layer and the API returns 409 Conflict on any of the three revert endpoints. The reason is COINS reconciliation: if the docket already landed in an ERP batch, locally undoing it would diverge the two systems. To recover an erroneously-pushed docket, force-fail the batch first via the ERP batch detail page and then walk the level-by-level revert from confirmed.
MAT- display ID
Per Decision #12, materials dockets reuse the existing dockets.docket_number sequence rather than introducing a separate display_id column. At creation, the materials handler computes the next docket_number via the existing service-layer logic, then prepends MAT- before persistence. The result looks like:
- Labour-plant:
12345 - Materials:
MAT-12346
The prefix is a creation-time concern — there’s no schema change, no backfill, no migration of existing labour-plant numbers. It also makes materials dockets visually distinguishable in any list, log, or audit entry where the docket_number is rendered.
Workbench Materials toggle
/admin/dockets has always been generic over docket_type — Slice 7 lights up a segmented control at the top:
[ Labour & Plant ] [ Materials ]- URL state:
?type=labour-plant(default for back-compat) or?type=materials. - Same backend handler, same SQL, swapped column set per toggle.
- Materials columns:
display_id,supplier,delivery_docket_no,source_location,delivery_location,line_count,total_qty,grand_total,status,exported. - Total Qty renders the per-unit subtotals Postgres formats (
570 ea + 3.5 t). Quantities print at fullNUMERIC(14,4)precision — the column used to round to one decimal and append a literal##. - Exported (formerly labelled
ERP) shows the date the docket was pushed, orNot sent. The external GRN reference is on the cell’s hover. The workbench filter chip is labelled Exported to match. - Filters: date range, supplier, status, supervisor — shared across toggles. The
docket_typesfilter is forced by the toggle so users don’t need to set it. - Bulk actions: submit / review / confirm / delete — same primitives, same visual treatment.
- Push to COINS (under Export ▾): pushes the selected confirmed materials dockets. See ERP Push.
- CSV Export: the materials toggle exports the materials column set; deterministic ordering by
display_id ASC.
Diary 5th tab
Per Decision #8, the diary detail page gains a fifth tab:
Labour | Plant | Subcontractor | Associated | MaterialsThe Materials tab renders the same GroupedDocketsTable used by the other tabs, with the materials column set. Two link paths converge here:
- Auto-link via the trigger: the existing
attach_orphan_dockets_to_diary()function from migration 058 has been extended (§4.6) to coverdocket_type='materials'. When a supervisor creates a diary for(project_id, date, supervisor_id), any existing draft materials dockets matching(project_id, date, primary_supervisor_id)getdiary_idback-filled automatically. There’s no shift_type discriminator on materials dockets — the trigger correctly handles the NULL case. - Manual creation from the tab: clicking + Add Delivery from the empty-state opens the Add Materials Docket sheet with
date,project_id, anddiary_idpre-populated.
Materials dockets created on dates without a diary remain orphaned in the workbench — visible, priced, allocated, just not diary-bound. If the supervisor later creates a diary for that date, the trigger back-fills retroactively. Materials dockets created by users who never own a diary (e.g. cost-controller-only weekend deliveries) stay workbench-only forever — that’s the correct behaviour per Q14.
Related
- Contracts — authoring materials and cost-allocation, the prerequisite for any docket.
- ERP Push — pushing confirmed materials dockets to COINS.
- Material Dockets Export — every delivery in a date range as a CSV.
- Spec:
docs/superpowers/specs/2026-04-30-materials-module-design.md§4 (data model), §6 (pricing), §7.2-7.4 (UI), §9 (edge cases).