Detention report — one row per facility visit with billable overage
GET/api/v1/reports/detention
Per-VISIT detention rows: arrival, departure, time on site, billable overage.
A carrier bills detention when a truck waits at a shipper or receiver beyond the
contracted free window. The claim needs a timestamped arrival and departure for a
single visit, so this report emits exactly one row per visit — a visit that spans
midnight, or several days, stays ONE row. (/reports/time-on-site buckets by
(device, geofence, local day) instead, which is the right shape for job-site
utilisation and the wrong shape for a claim.)
Visits come from GeofenceEvent entry/exit pairing (_pair_geofence_visits), which
scans SYMMETRICALLY past both ends of the window: it seeds a visit that was already
open when the window began, and resolves a visit that closes after the window ends.
Both matter because a truck that arrives at 18:00 and leaves at 03:00 is one claim,
and clipping either end understates the invoice. Dwell SignalEvents are deliberately
NOT used here: a dwell signal has no departure event, and a claim without a
departure timestamp is not payable.
NOT clipped to shift sessions. report_time_on_site applies _clip_to_shifts
because a van parked at a job site after the crew clocks out is not
working time. Detention is the exact opposite: a truck still sitting at a receiver
after the driver's shift ends is precisely the time being billed, and clipping it
would delete the claim. Do not "consistency-fix" this to match time-on-site.
A visit with no exit event splits into two distinct states. If a later re-entry
proves the truck left, the row is departure_inferred=true (NOT open) with
seconds_on_site bounded at that re-entry. Otherwise it is is_open=true with
seconds_on_site measured to the earlier of now and the range end — never a
silent whole-day figure. Both keep exited_at=null, because there is no departure
event to cite. summary.open_visit_count therefore counts only trucks still on
site; inferred departures are counted separately.
An open visit is only as good as the device behind it, and only while that device had
CONTINUOUS contact. A silence longer than settings.DETENTION_STALE_CONTACT_HOURS
anywhere inside the visit ends the evidence there and flags the row
contact_lost=true. Contact comes from DeviceSession.last_contact_at (see
_contact_intervals) — never from Device.last_location_time, which a reconnect
refreshes, and never from DeviceLocation rows, which the distance filter skips for a
parked truck. Those rows are counted and priced in their own bucket
(contact_lost_visit_count / contact_lost_unresolved_cents) and are excluded from
open_visit_count and from the owed total.
A visit that outlives its shift therefore stops being evidenced once the shift's telemetry does. That is not a clip to shift windows — the dwell keeps running and the money is still reported — but the platform cannot claim a truck was on site during hours it was not permitted to hear from the device (the device location routes reject off-shift fixes), so that portion sits in the unresolved bucket with its floor amount rather than in the claim total.
Money is integer cents end to end. over_free_time_seconds is
max(0, seconds_on_site - free_time_minutes * 60); amount_owed_cents applies
rate_per_hour_cents to it under the rounding rule in _detention_amount_cents.
The default rate of 0 yields 0 owed, so an unpriced report shows no dollar figure
rather than a fabricated one.
Evidenced money and unresolved money never mix. summary.total_amount_owed_cents and
summary.total_over_free_time_seconds cover evidenced and LIVE open visits only. The
two excluded magnitudes point in OPPOSITE directions and must not be described alike:
inferred_max_additional_cents is a CEILING (the truck may have left right after
arriving), while contact_lost_unresolved_cents is a FLOOR (the vehicle was there at
least that long and may have stayed far longer — the stop is unresolved, not cheap).
Per row the split is amount_owed_cents versus evidenced_amount_owed_cents (0 in
both cases), so a CSV export can be summed without silently billing a guess.
Request
Responses
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 500
OK
Bad Request
Unauthorized
Forbidden
Not Found
Conflict
Validation Error
Internal Server Error