ModulesDocketsCore Pricing

Core Pricing

A docket brought over from Core keeps the priced lines Core gave it. Its total is the sum of those lines.

What still works

Hours, breaks, activities, workers and the header can all be edited and saved. Submit, review and confirm work as usual.

What does not happen

Pro never recalculates the docket’s price. Not on an edit, a submit, a worker rate change, a bulk edit or a sign-on. To change the money, add or edit a line.

The price preview on a Core docket shows its saved price.

If every Core line on a docket is deleted, it becomes a normal Pro docket and Pro prices it from then on.

Sign-ons

A Core sign-on for a shift that already has a docket for that worker counts as billed. No second docket is made for it.

A sign-on only joins a draft docket. It never changes the rates or price of a docket that has been submitted, reviewed or confirmed.

History

These are recorded in the audit log:

  • a worker’s rate change on the field users page
  • every sign-on the system attaches to a docket
  • every worker row the system gives a classification

For developers

The marker. A docket holds Core pricing while it has a live docket_pricing_lines row with source_entity_type = 'legacy_core_docket_line' (deleted_at IS NULL). A line marked removed still counts. Status, the pricing blob and dockets.metadata play no part.

The guard. The three reprice bodies in backend/pkg/docketpricing return before the engine runs when the marker is there:

BodyFileHow it asks
Labour, plant, combinedreprice.goholds_core_lines on GetDocketForPricing
Subcontractorsubcontractor.goholds_core_lines on GetDocketForPricing
Materialsmaterials.goDocketHoldsLegacyCoreLines

The guard returns nil, never an error, and writes nothing: no lines, no totals, no priced_at, no blob. Every caller (edits, submit, auto-fix, unpin, the negotiation hook, the Temporal bulk recompute, the sign-on back-fill) goes through one of these bodies. TestCoreLinePredicateIsOneRule fails if either SQL copy of the predicate drifts from the other or from the Go constant.

Sign-ons. The individual-docket attach in backend/api/internal/prestart adopts a found docket only while it is a draft (attachAdoptsFoundDocket). For any other status it attaches no machine, stamps no rate, does not reprice and mints nothing. It still records a second supervisor on the docket, which moves no money. ListUnbilledSignOnsForWorker leaves out a Core sign-on (metadata.legacy.source = 'core') whose worker already has a docket on that shift, and any sign-on whose worker already has an individual docket past draft on that shift on a row that is not prestart_only. A wet-hire (prestart_only) row is left in, because a plain-labour sign-on skips it and mints the labour docket. A second wet-hire sign-on on a locked wet-hire docket stays listed and is refused each time; no money moves.

Preview. PreviewPricing reads the saved docket once, under the caller’s own docket scope, and reads its lines once. The bills_to freeze uses that same row’s status. If a line is Core’s, it returns the saved pricing view, the same one docket detail shows, before any engine work. A docket outside the caller’s scope is ignored and the preview is the plain engine run.

Audit actions. worker.attached_from_sign_on and worker.classification_backfilled on the docket, and field_user_updated on the project member when the single field-user save changes the rate. The back-fill, the classification patch and the crew fan-out write their rows in one statement after their loops (CreateDocketAuditLogsBatch).

Known gap. A Core line in manual mode bills from its cells. Deleting an activity that line has cells on, then editing the line, recomputes its amount. The reprice guard does not cover that path.

Tests. backend/pkg/docketpricing/core_pricing_test.go, backend/api/internal/docket/core_pricing_guard_integration_test.go, backend/api/internal/prestart/core_signon_backfill_integration_test.go and backend/api/internal/fielduser/rate_change_audit_integration_test.go.