Skip to content

feat(orders): status-guarded edits, order.changed events, driver offers - #394

Merged
roncodes merged 5 commits into
release/v0.6.72from
feat/order-edits-and-offers
Oct 13, 2026
Merged

roncodes merged 5 commits into
release/v0.6.72from
feat/order-edits-and-offers

Conversation

@roncodes

Copy link
Copy Markdown
Member

What and why

Fleet-Ops side of the order operations program: orders can be edited after they are placed without corrupting the driver's run, every edit is announced in realtime, drivers hear when an order is taken off them, drivers accept or decline the orders offered to them, and cancellations carry a reason.

Four feature commits, each one concern, plus a test-alignment commit:

  1. Status-guarded order edits that announce order.changed
    • OrderEditPolicy (one place for the lock rules, read from the order status and order config):
      • open (created, pending, accepted/confirmed, preparing): everything editable
      • ready (ready, pickup_ready): route, contact, notes, cash to collect; items removal only; schedule earlier only
      • with a driver (dispatched, started, enroute, driver_*, or any later custom status once dispatched): route (reroutes the driver), contact, notes, cash to collect; items and schedule locked
      • terminal (the config's complete or terminal activity codes, canceled, expired): nothing
    • Applied to PUT v1/orders/{id}, PATCH int/v1/orders/route/{id}, PUT v1/payloads/{id} and POST/PUT/DELETE v1/entities for entities on an order. A locked change answers 422 { "error": "...", "locked": { "<field>": "<reason>" } } before anything is written. Fields not covered by the policy (status, meta, assignment, internal id, ...) are untouched, so existing callers that update them keep working.
    • OrderEdit snapshots the order before an edit and, on commit:
      • only moves current_waypoint when the route actually changed. An order under way keeps the driver's current stop if it is still on the route, otherwise moves them to the stop that replaced it (new pickup/dropoff/return) or the next stop they have not completed. Orders not under way still restart at the first stop, as before.
      • recomputes distance and time (setDistanceAndTime), republishes public tracking (TrackingPublisher::routeChanged, reason route),
      • flags item changes made while preparing (meta.kitchen_changed_at),
      • broadcasts order.changed and pushes the assigned driver when the change concerns them.
    • Payload::syncEntities() (update by id/uuid, create entries without one, delete the rest). Used when an edit sends entities_mode=replace (top level or payload.entities_mode); appending stays the default so existing clients are unchanged.
    • Cash to collect on order update: payload.cod_amount or a top-level cod_amount; 0 is kept (the payload update no longer drops it through array_filter).
  2. Reassignment notifications, single OrderDriverAssigned
    • When a driver is taken off an order (unassign or reassign, from the observer and from the bulk unassign/assign paths that bypass the model) they get order.driver_unassigned and an order_unassigned push, and the order is cleared as their current job.
    • Order::assignDriver() no longer fires OrderDriverAssigned before saving; the observer fires it once after the save (drivers were getting it twice). A silent assignment is now actually silent.
  3. Driver offers: meta.offer lifecycle, POST v1/orders/{id}/accept|decline, fleetops:expire-offers (every minute) raising the driver_not_accepted alert, offer on the order resources.
  4. Cancel with a reason on the public and console cancel endpoints.

API additions

Endpoint Change
PUT v1/orders/{id} 422 { error, locked } for locked changes; entities_mode=replace; cod_amount / payload.cod_amount (0 allowed)
PUT v1/payloads/{id} same guard when the payload belongs to an order; entities_mode=replace; cod_amount: 0 kept
POST/PUT/DELETE v1/entities 422 when the order's status locks its items
PATCH int/v1/orders/route/{id} 422 on terminal orders; no longer resets the current waypoint of an order under way
POST v1/orders/{id}/accept new. Authenticated driver (or driver public id). Accepts the offer; an unassigned adhoc order is assigned to the driver. 403 if the order is not theirs, 422 if finished
POST v1/orders/{id}/decline new. { reason? }. Records the decline, unassigns the driver, notifies dispatch. 403 if not theirs, 422 if started
DELETE v1/orders/{id}/cancel optional reason
PATCH int/v1/orders/cancel optional reason (nullable string, max 1000)
Order resource (v1, index, webhook) offer (= meta.offer, null when none)

Company setting fleetops.offer_timeout_minutes (default 5, 0 disables expiry), read with Setting::lookupForCompany.

Event contract (SocketCluster)

All envelopes are { id: event_xxx, api_version, event, created_at, data }.

  • order.changed on order.{public_id}, order.{uuid}, driver.{public_id} + driver.{uuid} (when assigned), customer.{public_id} (when a customer exists), company.{company_uuid}:
    data = { id, changes: [...], summary, dropoff: { id, address, location: { type: "Point", coordinates: [lng, lat] } } | null, cod_amount, total, currency, changed_by, changed_at }.
    Change names emitted by Fleet-Ops: pickup, dropoff, return, waypoints, items, schedule, notes, contact, cod_amount (plus anything passed by the caller, e.g. total). Storefront fires the same event with OrderChanged::dispatchFor($order, $changes, $summary, $extra = [], $notifyDriver = true); $extra overrides total, currency, cod_amount, dropoff, changed_by, changed_at.
  • order.driver_unassigned on driver.{old public_id}, driver.{old uuid}, company.{company_uuid}: data = { id, driver }.
  • order.offer_expired, order.offer_accepted, order.offer_declined on order.{public_id}, order.{uuid}, company.{company_uuid}: data = { id, driver, offer }.

Push notifications (FCM/APN)

  • OrderChanged (driver, when changes include pickup/dropoff/return/waypoints/items/cod_amount/schedule/notes): title Order {tracking} changed, body = summary, data { type: "order_changed", id, changes }. On FCM changes is a comma-separated string (FCM data values must be strings), on APNs and the broadcast it is a list. Also broadcast on the driver channels as order.changed_notification.
  • OrderUnassigned (old driver): data { type: "order_unassigned", id }. Not sent to a driver who declined the order themselves (the broadcast still is).
  • OrderCanceled data gains reason when one was given.
  • Dispatch alerts (registered notifications, mail + database): DriverNotAccepted (order.driver_not_accepted, raised once per offer, recorded in meta.operational_alerts.driver_not_accepted) and OrderOfferDeclined (order.offer_declined).

meta.offer = { driver, offered_at, expires_at, accepted_at, declined_at, status: offered|accepted|declined|expired } (+ reason on decline). Directly assigned orders start offered; adhoc start with assign, and accept on an unassigned adhoc order, start accepted; starting an order accepts an open offer. An expired offer leaves the driver assigned (dispatch decides).

Validation

  • Pest, file by file as CI runs it (scripts/pest-file-runner.php equivalent, PHP 8.3): 3173 passed, 21 skipped, 44 failed on the branch vs 3139 passed, 21 skipped, 42 failed on release/v0.6.72 with the same vendor. The 2 extra failures were contract tests asserting the old behaviour (CancelOrderRequest rules; assignDriver notifying before save) and are updated in the last commit (both pass now). The remaining 42 failures are identical to the base branch and come from the local vendor's core-api lacking SocketToken (tracking channel/publisher, socket channels, Afaqy/Safee ingestion, tracking controller) and similar environment gaps, not from this change.
  • New tests: Unit/Support/OrderEditPolicyTest, Unit/Support/OrderOperationsTest (SQLite: entity sync, current-waypoint reconciliation, commit and order.changed payload/channels, driver push, offers and the expiry command, unassign broadcast), Unit/Notifications/OrderChangeNotificationsTest, Unit/Observers/OrderAssignmentObserverTest, Unit/Models/OrderCancelReasonTest, plus new cases in the API order/payload/entity and internal order controller contract tests.
  • php-cs-fixer fix --dry-run on the changed files: clean except formatting that already fails on the base branch in Support/TrackingUpdate.php, Notifications/OrderCanceled.php and tests/Feature/Http/Internal/OrderControllerContractsTest.php (left alone to keep the diff focused).
  • PHPStan (level max) was not used as a gate: it reports pre-existing errors across server/src (no Laravel extension), and nothing new beyond the same classes of noise.

Docs / API spec impact

  • fleetbase/postman: add POST /v1/orders/{id}/accept, POST /v1/orders/{id}/decline; document reason on DELETE /v1/orders/{id}/cancel, entities_mode and cod_amount on order and payload updates, the 422 { error, locked } response, and offer on the order schema. Label: needs-api-spec.
  • fleetbase.io: realtime events (order.changed, order.driver_unassigned, order.offer_*), driver push types (order_changed, order_unassigned), the edit lock policy, driver offers and the fleetops.offer_timeout_minutes setting. Label: needs-docs.

Notes / follow-ups

  • The generic console order update (PUT int/v1/orders/{id}) and the schedule endpoint are not guarded by the policy yet; the console route editor is.
  • The public update still accepts a raw status (unchanged, to keep existing clients working).
  • Every directly assigned order now gets an offer that expires after 5 minutes and raises driver_not_accepted if the driver has not accepted or started it. Driver apps that do not call accept yet will surface these alerts until they do; companies can set fleetops.offer_timeout_minutes to 0 to disable expiry.

Order edits now go through one lock policy (OrderEditPolicy) built on the
order config: open statuses edit freely, ready orders only remove items and
move the schedule earlier, orders a driver has keep route, contact, notes and
cash to collect editable but lock items and schedule, and completed, canceled
or expired orders lock everything. Locked changes answer 422 with
{ error, locked: { field: reason } } from the public order update, the
console route edit, and the payload and entity endpoints. Unrelated fields
(status, meta, assignment, ...) are not guarded.

OrderEdit wraps an edit: it snapshots the order, refuses locked changes and
on commit works out what changed. A changed route no longer resets the
current waypoint to the pickup: an order under way keeps the driver's stop,
or moves them to the stop that replaced it or the next one left. Distance
and time are recomputed, public tracking is republished (reason "route"),
item changes while preparing set meta.kitchen_changed_at, and the new
OrderChanged event broadcasts `order.changed` with the changes, a summary,
the drop-off, cash to collect and total. OrderChanged::dispatchFor() lets
other extensions announce their own edits; the assigned driver gets the new
OrderChanged push when the change concerns them.

Payloads gain syncEntities() (update by id, create new, delete missing),
used when an edit sends `entities_mode=replace`; appending stays the default.
The order update reads cash to collect from payload.cod_amount or a
top-level cod_amount, and the payload update keeps a cod_amount of 0.
…rDriverAssigned once

When an order's driver is removed or replaced, the old driver now hears
about it: `order.driver_unassigned` is broadcast on driver.{public_id},
driver.{uuid} and the company channel with { id, driver }, the new
OrderUnassigned push sends { type: "order_unassigned", id }, and the order
is cleared as their current job. This runs from the order observer (public
update, console update, assign, unassign-order) and from the bulk paths that
bypass the model (unassign-orders, bulk-assign-driver).

Order::assignDriver no longer fires OrderDriverAssigned itself before saving;
the observer fires it once after the save, so drivers stop getting the
assignment twice. A silent assignment is now silent for that save, and the
silent flag survives the public-id lookup.
Assigning a driver now starts an offer on the order, kept in meta.offer as
{ driver, offered_at, expires_at, accepted_at, declined_at, status } with
status offered, accepted, declined or expired. The offer is written in the
same save as the assignment (order observer "saving"), and by the bulk
assign path. The window is the company setting
fleetops.offer_timeout_minutes (default 5; 0 disables expiry). Adhoc
drivers who take an order through `start` with `assign` start accepted,
and starting an order accepts an open offer.

New public endpoints for the authenticated driver (or a `driver` id):
- POST v1/orders/{id}/accept: accepts the offer (assigns an unassigned
  adhoc order to the driver) and broadcasts `order.offer_accepted`.
- POST v1/orders/{id}/decline { reason? }: records the decline, unassigns
  the driver, broadcasts `order.offer_declined` and notifies dispatch
  (OrderOfferDeclined).

The scheduled fleetops:expire-offers command (every minute) expires offers
past expires_at, raises the `driver_not_accepted` operational alert once per
offer (DriverNotAccepted, meta.operational_alerts.driver_not_accepted) and
broadcasts `order.offer_expired` on order.{public_id}, order.{uuid} and
company.{uuid}. The driver stays assigned. The order resources expose `offer`.
DELETE v1/orders/{id}/cancel and PATCH int/v1/orders/cancel accept an
optional `reason` (string, up to 1000 characters). Order::cancelWithReason()
records it as the details of the canceled tracking status (the config's
activity is cloned, not changed) and carries that activity on the
OrderCanceled event, so NotifyOrderEvent and the driver's canceled
notification include it. The canceled notification data gains `reason` when
one was given. Order::cancel() keeps its signature and behaviour.
@roncodes
roncodes merged commit 0268737 into release/v0.6.72 Oct 13, 2026
4 of 6 checks passed
@roncodes
roncodes deleted the feat/order-edits-and-offers branch October 13, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant