Skip to main content
A work item’s quantity can be linked to the sum of one or more sources, optionally scaled by a multiplier and shifted by an offset. @stralos-co-nz/calculators defines the QuantityLinkV1 descriptor, the unit rule, and resolveQuantityLink, the one resolver api-estimate runs to write the stored quantity and feature-estimate runs to preview it. Sharing one function is what stops the preview and the stored figure drifting apart.
api-estimate persists QuantityLinkV1 descriptors. Work items, build-up lines, and calculator input fields can each carry a takeoff-driven quantity link. See Takeoff-driven links below for the three link targets and their rules. The package supplies shared resolution and validation for both hosts.

QuantityLinkV1

Each source is one of three kinds: Two sources are duplicates when they have the same kind and ids. Ids are compared case-insensitively; a calculator layerId is compared exactly.

Validation

quantityLinkIssues(link) checks structure only and returns QuantityLinkIssue[] (code, optional source index, message). An empty array means the descriptor is acceptable. It treats its input as untrusted, so a host can run it on a stored value as well as on a new one. Whether the named calculators, measurements, and rate items exist, and whether units match, is the host’s check at write time. A host validates every link write through quantityLinkTargetIssues(link, target), which calls quantityLinkIssues and then applies the target’s own restrictions. target is "workItem", "component" (build-up line), or "calculatorInput". See Link targets. A host validates each write through quantityLinkTargetIssues(link, target), which combines structural checks with these target restrictions:

Unit rule

canonicalUnit(unit) is the comparison fold used on both sides. ML is matched case-sensitively as megalitre first. Otherwise the unit is trimmed, lower-cased, ² and ³ become 2 and 3, ^ and full stops are removed, and inner whitespace is collapsed. The result is looked up in an alias table covering the unit spellings used by api-estimate, feature-estimate, and the calculators package, so lm, lin m, and lineal metre fold to metre, and sqm, sq m, and m2 fold to square_metre. An unrecognised unit is returned as the folded token, so Bag and bag compare equal. It is a comparison fold only; it does not change what the hosts store or display. quantityDimension(unit) gives a unit’s dimension, and outputKeyDimension(outputKey) gives a calculator output’s: Every other unit, including kilometre, millimetre, hectare, kilogram, lot, item, lump_sum, and hour, has no dimension and matches nothing, not even another dimensionless unit. Readings are in metres, square metres, cubic metres, and tonnes, so adding them to a kilometre item unconverted would be wrong by a factor of 1000.
  • A calculator or takeoff source must have the same dimension as the work item’s unit.
  • A rate-item source counts only rows whose canonical unit equals the work item’s canonical unit.

Rate-item rows

A rate-item source counts only whole-item rows: rows whose stored quantity is the job total. A row priced per unit of the work item consumes the linked quantity, so counting it would make the resolver’s input depend on its own output. Rows of other rate items are ignored. Rows of the named rate item that are not counted are returned in excluded with a reason: The first four reasons are exported as wholeItemExclusion(row) and isWholeItemPricedRow(row), which must stay in step with api-estimate’s and feature-estimate’s whole-item rules.

Resolver

resolveQuantityLink is pure and never throws. It is meant to run over stored descriptors during a whole-estimate save, so an invalid descriptor resolves stale with an invalid warning (and no source results) instead of failing an unrelated save.

QuantityLinkInput

QuantityLinkTakeoffInput.byTag is a ReadonlyMap<string, { label: string; quantity: number }> keyed by lower-cased tag id. It is required for a Multi-segment’s tag sources to resolve; any tag source on a measurement without byTag resolves missing. Build it from takeoffTagQuantities(parts, breakdown) (see Takeoff parts). Retired tags remain in byTag (a retired tag is not missing, per Q12).

QuantityLinkResolution

Output order is deterministic (sources in descriptor order, rows in components order), so a host can compare a new resolution with the stored one and write only on change.

Rules

  1. Stale, never partial. If any source is not ok, the whole link is stale: value and sum are null. Summing only the sources that resolved would silently under-count.
  2. Whole-item rows only. See Rate-item rows.
  3. One rounding. The sum (3 dp), multiplier (4 dp), and offset (3 dp) are held as scaled integers. The result is computed at 7 dp and rounded to 3 dp once, half up. For example, 571.650 × 1.05 = 600.2325, which resolves to 600.233.
  4. Round up last. A link with a rounding rounds the rule 3 result up to its step. See Round up to.

Source states

Stale causes (api-estimate)

When api-estimate stores a quantity_link_result or builds a 422 response, it adds a cause field to every non-ok source that gives a finer machine code than state. The reason string that accompanies it uses display units (for example, m² rather than square_metre) so the message is ready to show beside the source in the UI.

Warnings

api-estimate behaviour

Write-time 422 responses

When a link is written and a source fails the write-time check, the 422 body includes sources[]. Each entry has index, label, state, and, when api-estimate has assigned one, cause and reason. The reason string always speaks display units — m² not square_metre — so the client can show it directly beside the failing source. The two link-specific 422 codes are:

Calculator-owned row units

A build-up row fed by a project calculator (its generatedKey starts with calc:) has its unit set and reset by the calculator on every save. Changing the unit on such a row via PUT /work-item-components/:id or via the pricing-rule unit move returns:
  • Status: 422 Unprocessable Entity
  • Code: component_unit_calculator_owned
  • Message: names the calculator and the layer that owns the unit, e.g. “The unit of this row is set by Trench A · Layer 2. Choose another output in the calculator, or unlink the row from the calculator to set its unit yourself.”
  • Body: { origin: { calculatorId, calculatorName, layerId, layerLabel } }
An unchanged unit (echo) or a canonical respelling of the same unit (e.g. m3 → cubic_metre) passes without error. Use POST /work-item-components/:id/unlink-calculator to detach the row from the calculator and take ownership of its unit. GET /estimates/:estimateId/work-items/:itemId/quantity-link/candidates returns rateItems[]. Each eligible and excluded row now carries an origin field that describes where the row’s quantity comes from:

Round up to

From 2.1.0, QuantityLinkV1.rounding takes a QuantityRoundingOption (QUANTITY_ROUNDING_OPTIONS). The step is applied to Σ × multiplier + offset, always up; a value already on a step, zero, and a negative are unchanged. Absent or "none" resolves exactly as a pre-2.1.0 link. When quantityLinkRuns splits a rounded link into per-run figures, the rounding uplift (value − unrounded) is added to the last run, so the runs still sum to the rounded quantity. See Estimate rounding for the option list, labels, suggestQuantityRounding, and how api-estimate applies it.

Worked examples

The specification’s worked examples are published on a separate subpath so fixture data never reaches a consumer bundle through the main entry point:
Each call returns fresh QuantityLinkWorkedExample objects (id, title, link, input, expected) with new maps and arrays. Every expected value was calculated by hand from the specification, not captured from the resolver, so hosts can use them as parity tests. From 2.1.0 the set includes four rounding cases: round-1, round-ends-in, round-after-multiplier, and round-on-step.

Constants

A work item component (build-up line) can carry its own quantity_link on estimate_work_item_components. The descriptor is the same QuantityLinkV1 shape, but the allowed source kinds are restricted:
  • calculator and takeoff sources are accepted.
  • rateItem sources are not accepted. A line reading a rate item’s rows could end up reading itself, so that kind is excluded entirely.

Eligibility

Not every build-up line can carry a link. A line is eligible when it meets both conditions:
  • It is a whole-item priced row (isWholeItemPricedRow: not per-unit, not in an activity, not a copied activity child, not a container). A per-unit row consumes the work item’s quantity, so feeding it a total would double-count.
  • Its generatedKey is null. A calculator-generated or activity-generated row is rewritten by its owner on every save; the owner would overwrite the linked value.
GET /estimates/:estimateId/components/:componentId/quantity-link/candidates returns:

Resolution timing

Build-up line links are resolved at the head of every recomputeEstimateTotals run, before the work-item pre-pass reads the lines. This means a calculator save, reconcile, or delete moves the line’s quantity in the same request — not only when takeoff is written. Frozen estimates never re-resolve (SPEC D14). A stale source keeps the last stored quantity rather than zeroing it.

Calculator deletion and source-removing saves

When a calculator is deleted with ?unlink=true, or when PUT /calculators/:id would stop emitting a layer that a build-up line’s link names, the response includes unlinkedComponents alongside unlinked:
Each entry records the component that was unlinked and the quantity it kept. The 409 calculator_in_use and 409 calculator_source_removed usage bodies also include a components array for build-up lines that name the calculator, alongside the existing workItems array.

linkedUseCount on the calculator response

The linkedUseCount field returned by GET /estimates/:id/calculators counts how many live work items and build-up lines have a quantity_link naming the calculator. A work item or build-up line that reads several of the calculator’s readings still counts as one. This count drives the “ƒ n uses” indicator in the Calculations list. Three targets in an estimate can carry a takeoff-driven link. All three are updated automatically by the takeoff-driven pass, which runs before estimate totals are recomputed. Frozen estimates are never re-resolved. estimate_work_items.quantity_link stores a QuantityLinkV1 descriptor. The server resolves the linked quantity and writes it into the item’s quantity field. The takeoff source may be a measurement total or a Multi-segment or tagged Multi-line subtotal. PUT /estimates/:id/work-items/:itemId accepts quantityLink (a QuantityLinkV1 or null). Passing null unlinks and keeps the current quantity. A typed quantity that differs from the stored linked quantity is 422 quantity_linked unless it equals the stored value (equal resend passes). Build-up lines accept calculator readings and takeoff totals or tag subtotals. See Build-up line quantity links for eligibility and candidate endpoints. estimate_calculators.input_links stores a CalculatorInputLinksV1 ({v:1, fields}) where each field maps to a QuantityLinkV1 of takeoff sources only: a measurement total, or one or more tag subtotals of a Multi-segment or tagged Multi-line. Tag subtotals are in metres, so only a length field can take one. Supported fields: PUT /estimates/:estimateId/calculators/:id accepts inputLinks. The resolved value is written directly into config so the engine and reconcile run unchanged. Errors:
  • 422 calculator_input_link_kind — a non-takeoff source kind.
  • 422 quantity_link_unit: a source in another dimension from the field, for example a tag subtotal on volume.areaM2.
  • 422 quantity_link_takeoff_overlap: a measurement’s total and one of its own tag subtotals appear in the same field’s link.
  • 422 calculator_input_field_inapplicable — the field does not apply to the calculator’s type or configured mode.
  • 422 calculator_input_linked — a typed config value differs from the linked field’s resolved value.
  • 409 calculator_source_removed — a config change (for example, a volume mode switch) would orphan a linked field. Pass ?unlink=true to unlink that field and keep its value.
Candidates: GET /estimates/:estimateId/calculators/:calculatorId/input-link/candidates?field=<field> returns field, unit, applies, and takeoffs. takeoffs lists live measurements in the field’s dimension, each with its byTag tag subtotals where it has them, so an area field never sees a tag subtotal.

Takeoff usage

GET /estimates/:estimateId/takeoff-measurements/usage returns every live measurement’s total and each of its tags, with all three user types listed under each source. Works on frozen estimates. When a measurement PUT would remove a linked source, the server returns 409 takeoff_source_removed with usage and removed: {total, tags} before writing anything. Pass ?unlink=true to apply the edit and clear affected links atomically. From calculators 1.16.0, only a tag loses its source, when its last span or line is deleted or retagged, so removed.total is always false. Deleting a span or line that leaves its tag elsewhere, a move, an insert, or a non-last retag re-resolves every linked quantity instead (work items, build-up lines, and calculator inputs). DELETE /estimates/:estimateId/takeoff-measurements/:id and DELETE /estimates/:estimateId/takeoff-map-sheets/:sheetId both return 409 measurement_in_use when any work item, build-up line, or calculator field links to the measurement, listing workItems[], components[], and calculatorFields[]. Pass ?unlink=true to unlink every user and complete the delete in one request. The 200 response includes unlinkedComponents and unlinkedCalculatorFields alongside the existing unlinkedWorkItems.