Repository navigation
feat(orders): status-guarded edits, order.changed events, driver offers - #394
Merged
Merged
Conversation
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.
…reason rule and observer-sent assignment
This was referenced Oct 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
order.changedOrderEditPolicy(one place for the lock rules, read from the order status and order config):driver_*, or any later custom status once dispatched): route (reroutes the driver), contact, notes, cash to collect; items and schedule lockedPUT v1/orders/{id},PATCH int/v1/orders/route/{id},PUT v1/payloads/{id}andPOST/PUT/DELETE v1/entitiesfor 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.OrderEditsnapshots the order before an edit and, on commit:current_waypointwhen 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.setDistanceAndTime), republishes public tracking (TrackingPublisher::routeChanged, reasonroute),preparing(meta.kitchen_changed_at),order.changedand pushes the assigned driver when the change concerns them.Payload::syncEntities()(update byid/uuid, create entries without one, delete the rest). Used when an edit sendsentities_mode=replace(top level orpayload.entities_mode); appending stays the default so existing clients are unchanged.payload.cod_amountor a top-levelcod_amount;0is kept (the payload update no longer drops it througharray_filter).OrderDriverAssignedorder.driver_unassignedand anorder_unassignedpush, and the order is cleared as their current job.Order::assignDriver()no longer firesOrderDriverAssignedbefore saving; the observer fires it once after the save (drivers were getting it twice). A silent assignment is now actually silent.meta.offerlifecycle,POST v1/orders/{id}/accept|decline,fleetops:expire-offers(every minute) raising thedriver_not_acceptedalert,offeron the order resources.API additions
PUT v1/orders/{id}{ error, locked }for locked changes;entities_mode=replace;cod_amount/payload.cod_amount(0 allowed)PUT v1/payloads/{id}entities_mode=replace;cod_amount: 0keptPOST/PUT/DELETE v1/entitiesPATCH int/v1/orders/route/{id}POST v1/orders/{id}/acceptdriverpublic id). Accepts the offer; an unassigned adhoc order is assigned to the driver. 403 if the order is not theirs, 422 if finishedPOST v1/orders/{id}/decline{ reason? }. Records the decline, unassigns the driver, notifies dispatch. 403 if not theirs, 422 if startedDELETE v1/orders/{id}/cancelreasonPATCH int/v1/orders/cancelreason(nullable string, max 1000)offer(=meta.offer, null when none)Company setting
fleetops.offer_timeout_minutes(default 5,0disables expiry), read withSetting::lookupForCompany.Event contract (SocketCluster)
All envelopes are
{ id: event_xxx, api_version, event, created_at, data }.order.changedonorder.{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 withOrderChanged::dispatchFor($order, $changes, $summary, $extra = [], $notifyDriver = true);$extraoverridestotal,currency,cod_amount,dropoff,changed_by,changed_at.order.driver_unassignedondriver.{old public_id},driver.{old uuid},company.{company_uuid}:data = { id, driver }.order.offer_expired,order.offer_accepted,order.offer_declinedonorder.{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): titleOrder {tracking} changed, body = summary, data{ type: "order_changed", id, changes }. On FCMchangesis 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 asorder.changed_notification.OrderUnassigned(old driver): data{ type: "order_unassigned", id }. Not sent to a driver who declined the order themselves (the broadcast still is).OrderCanceleddata gainsreasonwhen one was given.DriverNotAccepted(order.driver_not_accepted, raised once per offer, recorded inmeta.operational_alerts.driver_not_accepted) andOrderOfferDeclined(order.offer_declined).meta.offer = { driver, offered_at, expires_at, accepted_at, declined_at, status: offered|accepted|declined|expired }(+reasonon decline). Directly assigned orders startoffered; adhocstartwithassign, andaccepton an unassigned adhoc order, startaccepted; starting an order accepts an open offer. An expired offer leaves the driver assigned (dispatch decides).Validation
scripts/pest-file-runner.phpequivalent, PHP 8.3): 3173 passed, 21 skipped, 44 failed on the branch vs 3139 passed, 21 skipped, 42 failed onrelease/v0.6.72with 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 lackingSocketToken(tracking channel/publisher, socket channels, Afaqy/Safee ingestion, tracking controller) and similar environment gaps, not from this change.Unit/Support/OrderEditPolicyTest,Unit/Support/OrderOperationsTest(SQLite: entity sync, current-waypoint reconciliation, commit andorder.changedpayload/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-runon the changed files: clean except formatting that already fails on the base branch inSupport/TrackingUpdate.php,Notifications/OrderCanceled.phpandtests/Feature/Http/Internal/OrderControllerContractsTest.php(left alone to keep the diff focused).server/src(no Laravel extension), and nothing new beyond the same classes of noise.Docs / API spec impact
POST /v1/orders/{id}/accept,POST /v1/orders/{id}/decline; documentreasononDELETE /v1/orders/{id}/cancel,entities_modeandcod_amounton order and payload updates, the 422{ error, locked }response, andofferon the order schema. Label:needs-api-spec.order.changed,order.driver_unassigned,order.offer_*), driver push types (order_changed,order_unassigned), the edit lock policy, driver offers and thefleetops.offer_timeout_minutessetting. Label:needs-docs.Notes / follow-ups
PUT int/v1/orders/{id}) and the schedule endpoint are not guarded by the policy yet; the console route editor is.status(unchanged, to keep existing clients working).driver_not_acceptedif the driver has not accepted or started it. Driver apps that do not callacceptyet will surface these alerts until they do; companies can setfleetops.offer_timeout_minutesto0to disable expiry.