> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Activities API

> Activities module API

The Activities API owns standard activities, nested activity structure, and activity components. Components can reference rate item IDs without taking ownership of Rates data.

## Base prefix

```text theme={null}
/api/activities
```

## Endpoint groups

| Group | Routes |
| - | - |
| Version | `GET /api/activities/version` |
| OpenAPI | `GET /api/activities/openapi.json` |
| Activities | `GET /activities`, `GET /activities/:id`, `POST /activities`, `POST /activities/from-components`, `PUT /activities/:id`, `DELETE /activities/:id` |
| Activity Components | `POST /activity-components`, `PUT /activity-components/:id`, `DELETE /activity-components/:id` |
| Production Profiles | `POST /activity-production-profiles`, `PUT /activity-production-profiles/:id`, `DELETE /activity-production-profiles/:id` |
| Rate sheets | `GET /rate-sheets`, `GET /rate-sheets/:id`, `POST /rate-sheets`, `PUT /rate-sheets/:id`, `DELETE /rate-sheets/:id`, `GET /rate-sheets/:id/refresh-preview`, `POST /rate-sheets/:id/refresh`, `POST /rate-sheets/:id/duplicate` |
| Activity calculators | `GET /activity-calculators`, `POST /activity-calculators`, `GET /activity-calculators/:id`, `PUT /activity-calculators/:id`, `PUT /activity-calculators/:id/links`, `GET /activity-calculators/substance-usage` |

## Version endpoint

```text theme={null}
GET /api/activities/version
```

Returns the running service's configured version. This endpoint is **unauthenticated** and always returns `Cache-Control: no-store`.

### Response

```json theme={null}
{ "version": "1.2.3" }
```

The endpoint reads `SERVICE_VERSION` and falls back to `package.json` when that variable is absent, such as in local development. Release coordinators compare this value with the manifest's minimum version before promotion.

## OpenAPI document endpoint

```text theme={null}
GET /api/activities/openapi.json
```

Returns the service's OpenAPI 3.1.0 document. This endpoint is **unauthenticated** and always returns `Cache-Control: no-store`. The document is built once at service startup from the route-descriptor registry and served as stable bytes — it is never recomputed per request.

The document uses `bearerAuth` (`http bearer JWT`) as its security scheme. Every
descriptor-backed business operation is protected. Health, version, and OpenAPI are
auth-exempt infrastructure routes and are intentionally excluded from the document's operations.

## Listing activities

```text theme={null}
GET /activities
```

Returns a paginated list of activities for the caller's organisation. Accepts the standard list query parameters (`start`, `length`) plus:

| Parameter | Type | Description |
| - | - | - |
| `search` | string | Filters by name or code. The value is matched as literal text — `%`, `_`, and `\` are treated as ordinary characters, not LIKE metacharacters. Case-insensitive. |
| `parentActivityId` | UUID | Restricts results to direct children of this parent activity. |

The response uses the standard paginated envelope: `items`, `totalCount`, `start`, and `length`. Both the `items` list and the `totalCount` are scoped to the same predicate, so the count always reflects the number of activities matching the current filters.

## Activity fields

Activities have the following writable fields.

| Field | Type | Required | Constraints | Description |
| - | - | - | - | - |
| `name` | string | Yes | Max 200 characters; unique within the organisation (case-insensitive, whitespace-trimmed) | Activity name. |
| `unit` | string | Yes | Max 40 characters | Unit of measure. |
| `status` | string | No | Max 40 characters; defaults to `active` | Activity status. |
| `code` | string | No | Max 80 characters | Optional short code; unique within the organisation. |
| `description` | string | No | Max 1000 characters | Optional description. |
| `parentActivityId` | UUID | No | — | Nests this activity under a parent. Must reference an activity in the same organisation. |
| `pricingMode` | string | No | `project_margins` or `fixed_price`; defaults to `project_margins` | Pricing mode for the activity. See [Package pricing](#package-pricing). |
| `defaultSellMargin` | number or null | No | 0–95; at most three decimal places; only allowed when `pricingMode` is `fixed_price` | Default sell margin as a percentage. Supplying this field on a `project_margins` activity returns `422 Unprocessable Entity`. |
| `sellAmount` | number or null | No | 0–10,000,000,000; at most two decimal places; only valid when `pricingMode` is `fixed_price` | Fixed sell price for the package. The server records the included build-up cost and timestamp alongside the sell in the same write, but only when the sell value changed. Send `null` to clear the recorded price. Omit the field to leave the stored price unchanged. Supplying this field on a `project_margins` activity returns `422 Unprocessable Entity`. See [Package pricing](#package-pricing). |
| `sortOrder` | integer | No | Min 0; defaults to `0` | Display order. |

### Activity name uniqueness

Activity names must be unique within each organisation. The check is case-insensitive and whitespace-trimmed, so `"Bulk Excavation"` and `" bulk excavation "` are treated as the same name. `POST /activities`, `POST /activities/from-components`, and `PUT /activities/:id` all enforce this rule on the server side.

When the submitted name matches an existing activity in the organisation, the server returns `409 Conflict`:

```json theme={null}
{
  "code": "activity_name_taken",
  "message": "An activity named \"Bulk excavation\" already exists in this organisation. Choose a different name."
}
```

The `message` field uses the stored spelling of the conflicting activity, not the submitted value. The response also includes `field: "name"` and `existingId` (the UUID of the conflicting activity) to help the caller identify the duplicate.

Saving an activity under its own current name (a no-change update) is allowed. The uniqueness check excludes the activity being updated.

Activity DTOs returned by the API also include computed and read-only fields:

* `pricingMode` — either `project_margins` (the activity prices through project margin settings) or `fixed_price` (the activity is a publishable package with its own sell amount). All existing activities default to `project_margins`.
* `defaultSellMargin` — the package's default sell margin as a percentage (0–95). Present only when `pricingMode` is `fixed_price`; `null` otherwise.
* `publishedSellAmount` — the sell amount at the time of the last publication. Present only on published fixed-price packages; `null` otherwise.
* `publishedCostAmount` — the cost build-up at the time of the last publication. Present only on published fixed-price packages; `null` otherwise.
* `publishedAt` — ISO 8601 timestamp of the last publication. Present only on published fixed-price packages; `null` otherwise.
* `componentCount` — total number of components on the activity.
* `estimatedCost` — sum of `quantity × resourceCount × unitCost` across the activity's **included** components. For rate-item lines this is the **low end** of the indicative range. **Optional components are excluded.**
* `optionalCost` — the same sum across the activity's **optional** components. For rate-item lines this is the low end of the indicative range.
* `estimatedCostRange` — the indicative cost range for the activity's included components as `{ min, max }`. Both ends are in dollars. `min` equals `estimatedCost`. When every component is unranged, `min` and `max` are equal. See [Indicative pricing for library lines](#indicative-pricing-for-library-lines).
* `optionalCostRange` — the same range for the optional components as `{ min, max }`. `min` equals `optionalCost`.
* `optionalComponentCount` — how many of the components are optional.
* `components` — the activity's component rows. Each component includes `rateGroup`, which is `null` when no group has been recorded, plus `optionality` and an `optionGroup` when it is optional. Rate-item components also include `unitCostRange` (see [Indicative pricing for library lines](#indicative-pricing-for-library-lines)).

> [!WARNING]
>
> **`estimatedCost` changed meaning.**
>
> It used to be the sum across *every* component. Since optional items shipped it is **included-only**, and `optionalCost` carries the rest. A consumer that still reads `estimatedCost` as "the whole build-up" now under-reads any activity with optional lines.
>
> Since indicative pricing shipped, `estimatedCost` is the **low end** of the range for any activity that has rate-item lines with multiple options. Use `estimatedCostRange.max` when you need an upper bound.
>
> `estimatedCost + optionalCost` is **not** a price anyone can be charged either: optional components may name an `optionGroup`, and **at most one line per group** may be ticked, so the sum adds up mutually-exclusive alternatives. Price a specific selection through the estimate insertion preview rather than by adding the two figures.
>
> Each side rounds **once**, half-up, to cents after all of its `quantity × resourceCount × unitCost` products have been summed — so `estimatedCost` is a sum-then-round figure, not a sum of rounded lines.

* `productionProfiles` — the activity's production profile rows (see [Production profile fields](#production-profile-fields)).

The three `published*` fields are atomic: either all three are present or all three are `null`. You cannot publish only a sell amount without a matching cost and timestamp.

## Indicative pricing for library lines

Activity library costs are indicative. The same build-up can be performed in different locations at different labour and plant rates, so a rate-item component is priced as a range across all live options for its rate item.

### unitCostRange on components

Each rate-item component gains a `unitCostRange` field at read time:

| Field | Type | Description |
| - | - | - |
| `min` | number | Lowest unit cost across the rate item's live options, in dollars. |
| `max` | number | Highest unit cost across the rate item's live options, in dollars. |
| `unit` | string | The unit both ends are priced in. Always the component's own unit. |
| `optionCount` | integer | Number of live options that priced into the range. When `optionCount` is `1`, `min` and `max` are equal. |

`unitCostRange` is `null` when:

* the component is not a rate-item line,
* the rate item has no live options reachable in the component's unit (options priced by step never count, see [Stepped items](#stepped-items)), or
* the rates service could not be reached.

Loadings and tier prices are included in both ends of the range. The range comes from the rates service's `POST /rate-items/lookup` batch read, which returns each referenced item's live options with their loaded prices. Each option's price is normalised to the component's unit; options whose unit conversion is not possible are excluded rather than priced as zero.

A minimum charge is not part of the per-unit range. It is a per-order floor and does not appear in this field.

### Stepped items

A rate option can be priced by step: its base pricing rule lists named steps, each a fixed amount for the whole line. A step is never multiplied by quantity or resource count, and the estimate picks the step.

api-rates cannot store a stepped rule yet (see [Stepped base rules](https://help.stralos.app/internal/pricing-module.md#stepped-base-rules)), so `stepAmountRange` stays `null` until it can.

A rate-item component whose item has live options priced by step gains a `stepAmountRange` field at read time:

| Field | Type | Description |
| - | - | - |
| `min` | number | Lowest amount among the pickable (not retired) steps on the item's live stepped options, in dollars. |
| `max` | number | Highest of those step amounts, in dollars. |
| `basis` | string | Always `"step"`: each end is a lump sum, not a per-unit price. |
| `stepCount` | integer | Number of pickable steps that priced into the range, across all of the item's stepped options. |

`stepAmountRange` is `null` when the component is not a rate-item line, the rate item has no live option priced by step, or the rates service could not be reached.

Stepped options never contribute to `unitCostRange`. When a line has no `unitCostRange` but does have a `stepAmountRange`, it contributes nothing to `estimatedCost`, `optionalCost` or their ranges, and its stored `unitCost` is not used in its place.

### estimatedCostRange and optionalCostRange on activities

The activity DTO includes two range fields that aggregate across all components:

* `estimatedCostRange` — `{ min, max }` for the included components. The low end prices every rate-item line at its cheapest live option; the high end at its most expensive. A non-rate-item line or one with no resolvable range contributes the same figure to both ends.
* `optionalCostRange` — the same range for optional components.

`estimatedCost` equals `estimatedCostRange.min`. `optionalCost` equals `optionalCostRange.min`. When every line has a single price or no live range is available, each range has equal minimum and maximum values. Included and optional totals remain separate and can differ.

### Rates service availability

The ranges are resolved outside any database transaction and cached per bearer token for 30 seconds. When the rates service is temporarily unavailable, `unitCostRange` is `null` for all rate-item lines, `estimatedCostRange.min` equals `estimatedCostRange.max`, and the range uses the stored fallback costs. The activity read still succeeds, but `estimatedCost` can differ from a successful live-rates read.

## Package pricing

Activities support two pricing modes, controlled by `pricingMode`.

### project\_margins mode

This is the default. The activity's cost is derived from its components and priced using the estimating workspace's margin rules. Package pricing fields (`defaultSellMargin`, `publishedSellAmount`, `publishedCostAmount`, `publishedAt`) must not be supplied on a `project_margins` activity — any attempt returns `422 Unprocessable Entity`.

### fixed\_price mode

A `fixed_price` activity is a subcontract package with a single sell price. It may carry a `defaultSellMargin` (a percentage). The sell price is recorded by supplying `sellAmount` in the same create or update request — there is no separate publish step.

Switching an activity from `fixed_price` back to `project_margins` clears all package pricing fields atomically.

### Recording a sell price

Supply `sellAmount` on `POST /activities`, `POST /activities/from-components`, or `PUT /activities/:id`. The server records the sell, the current included build-up cost snapshot, and the timestamp in the same transaction as the rest of the save.

| Supplied value | Effect |
| - | - |
| A number | Records the sell, cost snapshot, and timestamp. A value equal to the already-recorded amount to the cent re-records nothing. |
| `null` | Clears the recorded sell, cost, and timestamp. |
| Omitted | Leaves the recorded values unchanged. |

**Validation**

* `sellAmount` may only be supplied when `pricingMode` is `fixed_price` — otherwise `422 Unprocessable Entity`.
* Supplying `sellAmount` alongside `pricingMode: "project_margins"` in the same request returns `422 Unprocessable Entity`.
* The included build-up cost must fit within the service's `numeric(14,2)` money boundary — otherwise `422 Unprocessable Entity`.

A `defaultSellMargin` is not required. When it is supplied with `sellAmount`, the margin, sell, cost, and timestamp are saved atomically. The inline `sellAmount` has no tolerance check against the build-up cost.

### Margin lock

Once a sell price is recorded, the `defaultSellMargin` cannot be changed through a `PUT /activities/:id` request that does not also update the price. To change the margin on a priced activity, either:

* clear the price first by sending `sellAmount: null`, then update, or
* supply both a new `defaultSellMargin` and an updated `sellAmount` in the same request.

Attempting to change the margin via `PUT` while a price is recorded without also changing the price returns `409 Conflict`.

### Changing the basis unit when profiles exist

When updating an activity (`PUT /activities/:id`), changing the `unit` field while the activity has production profiles requires an extra confirmation field:

| Field | Type | Required | Description |
| - | - | - | - |
| `confirmUnitChange` | boolean | Conditional | Must be `true` when changing `unit` on an activity that already has production profiles. Omit or set `false` when no unit change is intended. If absent and profiles exist, the server returns `409 Conflict`. |

## Activity component fields

Activity components have the following writable fields.

| Field | Type | Required | Constraints | Description |
| - | - | - | - | - |
| `activityId` | UUID | Yes | Must reference an activity in the caller's organisation | The parent activity. |
| `name` | string | Yes | Max 200 characters | Component name. |
| `type` | string | Yes | Max 40 characters | Component type (e.g. labour, plant, material). |
| `unit` | string | Yes | Max 40 characters | Unit of measure. |
| `code` | string | No | Max 80 characters | Optional short code. |
| `category` | string | No | Max 120 characters | Optional category. |
| `rateGroup` | string or null | No | One of `labour`, `plant_vehicles`, `quarry_materials`, `other_materials`, `subcontractors`, `disposal`; or `null` | Rate group snapshot. On create, `null` or omission stores no group. On update, omission preserves the stored group and explicit `null` clears it. An invalid token returns `400`. |
| `rateItemId` | UUID | No | — | Soft reference to a rate item. The activities service stores the ID but does not validate it against the Rates service. |
| `childActivityId` | UUID | No | — | Soft reference to a nested activity. Must reference an activity in the same organisation when supplied. |
| `optionality` | string | No | `included` or `optional`; defaults to `included` | Whether this component is included in the package cost or listed as an optional addition. See [Component optionality](#component-optionality). |
| `optionGroup` | string or null | No | Max 120 characters; only allowed when `optionality` is `optional` | Groups related optional components under a shared label (e.g. a cartage band). Setting this on an `included` component returns `422`. |
| `quantity` | number | No | Min 0; defaults to `1` | Quantity. |
| `resourceCount` | number | No | Min 0; defaults to `1` | Resource count. |
| `quantityBasis` | string or null | No | `whole_activity` or `per_count`; or `null` | Controls how the component quantity is interpreted. `whole_activity` applies the component once regardless of count; `per_count` scales the component with the activity quantity. `null` means no basis is set. An invalid value returns `400`. |
| `unitCost` | number \| null | No | Non-negative when supplied; defaults to `0` if omitted | Unit cost. `null` is valid for a rate-item component whose item has multiple price options. The read endpoint reports an indicative range for such lines until an estimate chooses a price. |
| `sortOrder` | integer | No | Min 0; defaults to `0` | Display order. |
| `metadata` | object | No | Defaults to `{}` | Arbitrary key–value metadata. |

Activity component DTOs returned by read endpoints also include these computed fields:

| Field | Type | Description |
| - | - | - |
| `unitCostRange` | object or null | Indicative unit-cost range for a rate-item component. `null` on non-rate-item lines, on lines whose item is priced only by step, and when rates could not be reached. See [Indicative pricing for library lines](#indicative-pricing-for-library-lines). |
| `stepAmountRange` | object or null | Range of step amounts for a rate-item component whose item is priced by step. `null` otherwise. See [Stepped items](#stepped-items). |

### Component optionality

Components on an activity may be marked as `included` (the default) or `optional`.

* **Included** components contribute to `estimatedCost` on the activity DTO and are used to compute the package sell when publishing.
* **Optional** components contribute to `optionalCost` and `optionalComponentCount`. They are not included in the package cost roll-up.

An optional component must reference a `rateItemId` or a `childActivityId`. Setting `optionality` to `optional` without either returns `422 Unprocessable Entity`.

### Legacy embedded calculator rows

Component rows previously generated by the embedded calculator are identified by a `calculatorGenerated: true` flag in their `metadata`. These rows are preserved as ordinary components unless the `calculator` column is explicitly set to `null`, which deletes them all. New calculator rows are generated through the Activity Calculators entity, not through the `calculator` column.

## Production profile fields

Production profiles store output rates for time-based activities. They are managed via their own endpoints under the activities base prefix.

### Create a production profile

```text theme={null}
POST /api/activities/activity-production-profiles
```

| Field | Type | Required | Constraints | Description |
| - | - | - | - | - |
| `activityId` | UUID | Yes | Must reference an activity in the caller's organisation | The parent activity. |
| `outputDescription` | string | Yes | 1–200 characters | Label for this profile. |
| `outputUnit` | string | Yes | 1–40 characters | Unit of output, such as `m`. |
| `productionRate` | number | Yes | Greater than zero | Output quantity per unit of the activity's basis. |
| `notes` | string | No | Max 1000 characters | Additional notes. |
| `isDefault` | boolean | No | Defaults to `false` | Whether this is the default production profile. Only one profile per activity should be marked as default. |
| `sortOrder` | integer | No | Min 0 | Display order. |

The write requires a signed-in admin or member of the organisation. A service token receives `403 Forbidden`.

The condition fields `depthMinM`, `depthMaxM`, `diameterMinMm`, `diameterMaxMm` and `groundType` are not part of the API. A body that still carries them is accepted, but the values are stripped and nothing is stored.

### Update a production profile

```text theme={null}
PUT /api/activities/activity-production-profiles/:id
```

Accepts the same fields as the create body except `activityId`; every update field is optional. `outputDescription` and `outputUnit` must remain non-empty when supplied. Send `null` or an empty string to clear `notes`; omitting a field leaves its stored value unchanged.

### Delete a production profile

```text theme={null}
DELETE /api/activities/activity-production-profiles/:id
```

Deletes the profile and returns `204`. The write requires a signed-in admin or member of the organisation. The profile is kept for history: it leaves the activity's profile list and every read, and it stops being the default. See [Deleting activities, components and profiles](#deleting-activities-components-and-profiles).

### Production profile DTO fields

DTOs returned by the API include:

| Field | Type | Description |
| - | - | - |
| `id` | UUID | Profile identifier. |
| `organisationId` | UUID | Owning organisation. |
| `activityId` | UUID | Parent activity. |
| `outputDescription` | string | Profile label. |
| `outputUnit` | string | Output unit. |
| `productionRate` | number | Output per unit of the activity's basis. |
| `notes` | string or null | Additional notes. |
| `isDefault` | boolean | Whether this is the default profile. |
| `sortOrder` | integer | Display order. |
| `createdAt` | ISO 8601 string | Creation timestamp. |
| `updatedAt` | ISO 8601 string | Last update timestamp. |

## Deleting activities, components and profiles

```text theme={null}
DELETE /api/activities/activities/:id
DELETE /api/activities/activity-components/:id
DELETE /api/activities/activity-production-profiles/:id
```

Each delete returns `204`. Deleting an activity requires an organisation admin; a member receives `403`. Deleting a component or production profile requires a signed-in admin or member. None of them removes the row: it is kept for history with `deletedAt` (and the acting user as `deletedBy`).

* **Lists hide deleted rows.** `GET /activities` (including `search`) and the components and profiles embedded in an activity leave them out. A deleted activity frees its name and `code` for a new activity.
* **`GET /activities/:id` still returns a deleted activity**, with `deletedAt` set. Estimates that already use the activity keep resolving it, so they are unaffected.
* **Deleting an activity leaves its components and profiles untouched.** It releases the activity's library-calculator assignments: its links are removed, and a calculator whose default destination it was is left with no default. Those calculators stop writing rows into it, and the rows they generated before the delete are kept as they were. A deleted activity cannot be chosen as a calculator destination (`422 calculator_destination_out_of_library`).
* **Deleting a production profile** also clears its `isDefault`.
* **A repeat delete** returns `204` and changes nothing.
* **Writes to a deleted row return `409` with code `deleted`.** That covers `PUT` on a deleted activity, component or profile, and creating or updating a component or profile under a deleted activity.

An activity's own `inactive` status is unrelated: it is a working state you choose, and an inactive activity stays listed.

## Parent and child references

The API validates activity references when creating activities or components. `parentActivityId` on an activity and `childActivityId` on a component are soft references — bare UUIDs, not database foreign keys. The service validates them on write: a missing or foreign-organisation reference returns `422 Unprocessable Entity` and writes nothing.

`activityId` on a component is a real composite foreign key backed by a database constraint. Deleting an activity does not remove its components; see [Deleting activities, components and profiles](#deleting-activities-components-and-profiles).

`rateItemId` on a component is a soft reference to the Rates service. The activities service stores the ID but does not validate it.

## Atomic activity and component creation

```text theme={null}
POST /activities/from-components
```

This endpoint creates an activity and all its components in one transaction. Either the complete activity is saved or nothing is written. Use it instead of a separate activity create followed by multiple component creates when an incomplete, silently under-priced activity would be unsafe.

The write requires a signed-in admin or member of the organisation. A service token receives `403 Forbidden`.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `activity` | object | Yes | Same activity fields accepted by `POST /activities`. |
| `components` | array | No | Component rows to create. Defaults to an empty array; maximum 500 rows. |
| `productionProfiles` | array | No | Production profile rows to create with the activity. Defaults to an empty array; maximum 50 rows. |
| `idempotencyKey` | string | No | Opaque replay guard, unique within the organisation. Maximum 200 characters. |

Component entries use the writable fields from `POST /activity-components`, except `activityId`, which the server sets to the newly created activity. In this batch shape, optional `rateItemId` and `childActivityId` references may be omitted or explicitly set to `null`. The `rateGroup`, `optionality`, and `optionGroup` fields are accepted in the same way as the individual create endpoint — optionality defaults to `included`.

Production profile entries use the create fields above except `activityId`, which the server also sets. Profiles require a time-based activity basis. A batch can contain at most one profile marked as the default.

### Idempotency

An idempotency key identifies one activity within one organisation:

* A new key creates the activity and components and returns `201 Created` with `idempotentReplay: false`.
* A previously used key returns the original activity and its components with `200 OK` and `idempotentReplay: true`.

Concurrent requests using the same organisation and key are also constrained to one activity. Different organisations may use the same key independently. Requests without a key create a new activity each time.

### Validation and errors

* A malformed activity, component, production profile, or idempotency key returns `400 Bad Request`.
* An unauthenticated request returns `401 Unauthorized`; a service token returns `403 Forbidden`.
* The activity's `parentActivityId` and every component `childActivityId` must reference an activity in the caller's organisation. Missing or foreign references return `422 Unprocessable Entity` and write nothing.
* More than 500 components returns `422 Unprocessable Entity`.
* More than 50 production profiles, multiple default profiles, or profiles on a
  non-time-based activity returns `422 Unprocessable Entity`.
* An activity code already used by the organisation returns `409 Conflict`.
* An activity name already used by the organisation (case-insensitive, whitespace-trimmed) returns `409 Conflict` with code `activity_name_taken`.

### Response

The response is the complete activity DTO, including `componentCount`, `estimatedCost`, `components`, `productionProfiles`, and `idempotentReplay`.

## Rate sheets

A rate sheet is an organisation-owned snapshot of published fixed-price activity
sells. Each line freezes the source activity's name, unit, and sell amount.
Later library changes do not alter the sheet until an admin explicitly refreshes
it.

Reads are available to authenticated organisation members. Create, update,
archive, refresh, and duplicate require an organisation admin; other callers
receive `403`.

### Eligible activities

A source activity must be active, use `fixed_price`, have a non-null
`publishedSellAmount`, and belong to the caller's organisation. A mutation that
touches lines validates the complete set atomically. An ineligible, foreign, or
missing activity returns `422`, as does a repeated activity id.

### Sheet and line fields

| Sheet field | Type | Description |
| - | - | - |
| `id`, `organisationId` | uuid | Sheet identity and tenant owner. |
| `name` | string | Required name, up to 200 characters and unique among active sheets in the organisation. |
| `clientName` | string \| null | Optional display name, up to 200 characters. |
| `adjustmentPercent` | number | Adjustment from `-95` to `1000`, with at most three decimal places. Defaults to `0`. |
| `lines` | array | Ordered frozen activity snapshots. |
| `includeLogo` | boolean | Whether rendered output should include the organisation logo. Defaults to `true`. |
| `termsText` | string \| null | Optional terms, up to 4,000 characters. |
| `status` | `active` \| `archived` | Archived sheets are readable but immutable. |
| `createdBy` | uuid \| null | User who created this sheet. |
| `createdAt`, `updatedAt` | datetime | Audit timestamps. |

| Line field | Type | Description |
| - | - | - |
| `activityId` | uuid | Source package activity. |
| `name` | string | Frozen display name, up to 200 characters. |
| `unit` | string | Frozen unit, up to 40 characters. |
| `sellAmount` | number | Frozen published sell, from `0` to `10,000,000,000` and at cents precision. |
| `adjustedSellAmount` | number | Server-computed adjusted amount; never stored. |
| `sortOrder` | integer | Non-negative display order. |

The server computes `adjustedSellAmount` on every response with scaled integer
arithmetic and half-up rounding:

```text theme={null}
adjustedCents = roundHalfUp(sellCents × (100000 + adjustmentThousandths) / 100000)
```

An adjusted result outside JavaScript's safe-integer cents range returns `422`.

### List and get sheets

```text theme={null}
GET /api/activities/rate-sheets
GET /api/activities/rate-sheets/:id
```

The list contains active sheets only and returns `items`, `totalCount`, `start`,
and `length`. It orders by name, then id.

| Query parameter | Description |
| - | - |
| `start` | Zero-based offset; defaults to `0`. |
| `length` | Page size from 1 to 200; defaults to `50`. |
| `search` | Case-insensitive literal match against name or client name. `%`, `_`, and `\` are ordinary characters. |

Get-by-id can read either an active or archived sheet. A missing or foreign
sheet returns the same `404` response.

### Create a sheet

```text theme={null}
POST /api/activities/rate-sheets
```

The strict body requires `name` and an ordered `activityIds` array. Optional
fields are `clientName`, `adjustmentPercent`, `includeLogo`, and `termsText`.
The array may be empty. The service snapshots eligible activities in the given
order and assigns zero-based `sortOrder` values. It returns `201` with the new
sheet. A duplicate active name returns `409`.

### Update a sheet

```text theme={null}
PUT /api/activities/rate-sheets/:id
```

At least one of `name`, `clientName`, `adjustmentPercent`, `includeLogo`,
`termsText`, or `lines` is required. Supplying `lines` replaces the complete
snapshot array. Each strict input line requires `activityId`, `name`, `unit`,
`sellAmount`, and `sortOrder`; the service rechecks unique ids and live activity
eligibility, but preserves the submitted display and sell snapshots.

An archived sheet returns `409`. Send `null` to clear `clientName` or
`termsText`; omission leaves a field unchanged.

### Archive a sheet

```text theme={null}
DELETE /api/activities/rate-sheets/:id
```

This sets `status` to `archived`, preserves all snapshot data, and returns
`204`. Repeating it is an idempotent `204`. Archived sheets leave the list and
reject update or refresh with `409`.

### Preview a refresh

```text theme={null}
GET /api/activities/rate-sheets/:id/refresh-preview
```

Returns a read-only preview of what a refresh would change without writing anything. The response includes the current saved sheet, the proposed sheet after applying the latest published values, a per-line `changes` array, a count of lines that would change, and a `planHash`.

The `planHash` is a SHA-256 digest of the sheet's current state and every proposed price, name, and unit. It covers the sheet id, organisation, `updatedAt` timestamp, adjustment settings, and the ordered line tuples for both the saved and proposed snapshots. Pass it to the refresh endpoint to guard against concurrent edits.

An authenticated organisation member may call this endpoint; org admin is not required.

**Response**

| Field | Type | Description |
| - | - | - |
| `planHash` | string | 64-character hex SHA-256 digest of this refresh plan. Pass as `expectedPlanHash` to lock the subsequent commit. |
| `sheet` | object | The current saved sheet as a rate sheet DTO. |
| `proposedSheet` | object | The sheet as it would look after refresh. |
| `changes` | array | Per-line change details — see [Change fields](#change-fields) below. |
| `changedCount` | integer | Number of lines whose sell amount or metadata (name or unit) would change. |

An archived sheet returns `409`. A missing or foreign sheet returns `404`.

### Refresh snapshots

```text theme={null}
POST /api/activities/rate-sheets/:id/refresh
```

Refresh revalidates every existing source activity, then replaces each line's
name, unit, and sell with the current published package values while preserving
row order. If any source is now ineligible, the complete request returns `422`
and nothing changes.

**Optional stale-plan guard**

The request body accepts an optional `expectedPlanHash` field:

```json theme={null}
{ "expectedPlanHash": "<64 hex characters from refresh-preview>" }
```

When supplied, the service recomputes the plan hash inside the same write transaction (after acquiring the sheet lock and reading share-locked activity rows) and compares it with the submitted value. If they differ — because the sheet was updated or a source activity's published price changed since the preview — the request returns `409` without writing:

```json theme={null}
{ "code": "stale_rate_sheet_refresh", "message": "The rate sheet or published prices changed. Review a fresh preview before refreshing." }
```

Omitting `expectedPlanHash` skips the check and applies the refresh unconditionally. This preserves compatibility with callers that predated the preview endpoint.

The response contains the updated `sheet` and a `changes` entry for each line.

### Change fields

Each entry in the `changes` array describes one line:

| Field | Type | Description |
| - | - | - |
| `activityId`, `name` | string / uuid | Refreshed source identity. |
| `previousName`, `previousUnit` | string | Name and unit stored on the line before refresh. |
| `currentUnit` | string | Unit after refresh. |
| `previousSellAmount`, `currentSellAmount` | number | Sell before and after refresh. |
| `deltaAmount` | number | Exact cents difference, `currentSellAmount − previousSellAmount`. |
| `changed` | boolean | Whether the published sell amount changed. |
| `metadataChanged` | boolean | Whether the name or unit changed. A metadata-only change still counts toward `changedCount` in the preview response. |
| `previousAdjustedSellAmount`, `currentAdjustedSellAmount` | number | Adjusted sell amounts before and after refresh, computed from the sheet's `adjustmentPercent`. |
| `deltaAdjustedAmount` | number | Exact cents difference between adjusted sell amounts. |

### Duplicate a sheet

```text theme={null}
POST /api/activities/rate-sheets/:id/duplicate
```

The strict body requires a new `name`; `clientName` and `adjustmentPercent` may
override the source. The service copies `lines`, `includeLogo`, and `termsText`
without rechecking live activity eligibility. An archived source is allowed and
always produces a new active sheet. It returns `201`; an active-name collision
returns `409`.

## Activity calculators

Activity calculators are reusable organisation-owned quantity engines for trench, pit, and volume geometry. Their assignment register directs layer outputs to component rows on one or more activities. Reads are available to authenticated organisation members. Create, update, and link operations require a signed-in admin or member; a service token receives `403`.

```text theme={null}
GET    /api/activities/activity-calculators
GET    /api/activities/activity-calculators/substance-usage
POST   /api/activities/activity-calculators
GET    /api/activities/activity-calculators/:id
PUT    /api/activities/activity-calculators/:id
PUT    /api/activities/activity-calculators/:id/links
```

### Calculator fields

| Field | Description |
| - | - |
| `id`, `name`, `type` | Stable identity, display name, and `trench`, `pit`, or `volume` type. Names need not be unique. |
| `config` | Geometry configuration. Pricing fields are rejected because pricing belongs on activity component rows. |
| `defaultActivityId` | Default destination. Clearing it removes links that inherit the default. |
| `links` | Layer-to-activity assignment register. |
| `usedBy` | Distinct activities reached by at least one link, ordered by code then name. Each entry has `activityId`, `activityCode` (`null` when the activity has no code), and `activityName`. |

Each link names a `layerId`, destination `activityId`, physical `outputKey`, optional excavation `share`, and whether it `inheritsDefault`. The response also carries its stable `generatedKey` and current `componentRowId`.

### List and read

`GET /activity-calculators` lists calculators. Pass `activityId` to list only calculators feeding that activity. `GET /activity-calculators/:id` returns one calculator with its links and usage.

### Create

`POST /activity-calculators` requires `type` and `config`. `name` and `defaultActivityId` are optional. When the name is absent, the service allocates the next calculator name. A default destination seeds one inheriting link for each output layer.

An unreadable config returns `422 calculator_config_unreadable`. A config carrying pricing returns `422 calculator_config_carries_pricing`.

### Update and rename

`PUT /activity-calculators/:id` is partial. Omitted fields remain unchanged. Updating `config` or `defaultActivityId` runs the engine, rewrites the relevant register state, and reconciles every affected activity component row. Passing `defaultActivityId: null` clears the default and its inherited links.

A body containing only `name` takes the rename path. It changes no geometry, links, or component rows and does not run the engine.

### Replace assignment links

`PUT /activity-calculators/:id/links` replaces the complete register atomically and reconciles all affected activity rows.

A non-excavation layer accepts one whole-layer destination. An excavated stratum may be partitioned across activities, but its shares cannot exceed 100%, duplicate destinations are rejected, and a whole-layer assignment cannot be mixed with shares. Layer and output keys must come from the engine. An under-allocated stratum succeeds with an `excavation_partition_under` warning.

### No delete or copy route

There is no `DELETE /activity-calculators/:id`. It had no caller and was removed in the soft-delete programme; a request to it returns `404`.

There is no `POST /activity-calculators/:id/copy` either. The only screen that used it was removed, so the route was removed too (October 2026). A request to it returns `404`. To make a similar calculator, create a new one with the same geometry.

When a save reconciles an activity's rows, rows the calculator no longer generates are kept for history with `deletedAt` rather than removed. Every row swept by one save shares one `deletedAt`.

### Disposal substance usage

`GET /activity-calculators/substance-usage` returns `{ items: [{ substanceId, count }] }`. It counts distinct calculators in the organisation that reference each disposal substance, ordered by count descending and then substance id. A calculator counts once per substance even when several strata use it.

### Warnings and errors

Create, update, copy, and link replacement can return service `warnings` and engine `engineWarnings`. Clients must tolerate engine warning kinds they do not recognise. Unknown layers, unsupported outputs, foreign destinations, invalid shares, malformed backfill sources, and unreadable configs return `422` without a partial register update.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.