From 7a8104a2f3562d3859039757276f21bee5ba360c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:31:22 +0800 Subject: [PATCH 1/5] feat(orders): status-guarded order edits that announce order.changed 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. --- server/src/Events/OrderChanged.php | 257 ++++++++++++ .../Exceptions/OrderEditLockedException.php | 32 ++ .../Controllers/Api/v1/EntityController.php | 66 +++ .../Controllers/Api/v1/OrderController.php | 125 +++++- .../Controllers/Api/v1/PayloadController.php | 84 +++- .../Internal/v1/OrderController.php | 40 +- server/src/Models/Order.php | 9 + server/src/Models/Payload.php | 241 +++++++++-- server/src/Notifications/OrderChanged.php | 87 ++++ .../src/Providers/FleetOpsServiceProvider.php | 1 + server/src/Support/OrderEdit.php | 365 +++++++++++++++++ server/src/Support/OrderEditPolicy.php | 292 ++++++++++++++ server/src/Support/TrackingPublisher.php | 12 + server/src/Support/TrackingUpdate.php | 2 + .../ApiEntityControllerContractsTest.php | 84 ++++ .../tests/ApiOrderControllerContractsTest.php | 140 +++++++ .../ApiPayloadControllerContractsTest.php | 121 ++++++ .../Internal/OrderControllerContractsTest.php | 76 +++- .../OrderChangeNotificationsTest.php | 47 +++ .../Unit/Support/OrderEditPolicyTest.php | 128 ++++++ .../Unit/Support/OrderOperationsTest.php | 379 ++++++++++++++++++ 21 files changed, 2526 insertions(+), 62 deletions(-) create mode 100644 server/src/Events/OrderChanged.php create mode 100644 server/src/Exceptions/OrderEditLockedException.php create mode 100644 server/src/Notifications/OrderChanged.php create mode 100644 server/src/Support/OrderEdit.php create mode 100644 server/src/Support/OrderEditPolicy.php create mode 100644 server/tests/Unit/Notifications/OrderChangeNotificationsTest.php create mode 100644 server/tests/Unit/Support/OrderEditPolicyTest.php create mode 100644 server/tests/Unit/Support/OrderOperationsTest.php diff --git a/server/src/Events/OrderChanged.php b/server/src/Events/OrderChanged.php new file mode 100644 index 000000000..74a356195 --- /dev/null +++ b/server/src/Events/OrderChanged.php @@ -0,0 +1,257 @@ +eventId = uniqid('event_'); + $this->sentAt = Carbon::now()->toDateTimeString(); + $this->channelNames = static::channelNamesFor($order); + $this->data = static::dataFor($order, $changes, $summary, $extra); + } + + /** + * Broadcast `order.changed` for the order and, unless told otherwise, push the + * change to the assigned driver when it concerns them. + * + * Storefront and other extensions call this after their own order edits. + */ + public static function dispatchFor(Order $order, array $changes, string $summary = '', array $extra = [], bool $notifyDriver = true): ?self + { + $changes = array_values(array_unique(array_filter($changes, 'is_string'))); + if ($changes === []) { + return null; + } + + $event = new static($order, $changes, $summary, $extra); + + try { + event($event); + } catch (\Throwable $exception) { + Log::warning('Unable to broadcast order.changed', ['order' => $order->public_id, 'error' => $exception->getMessage()]); + } + + if ($notifyDriver && static::concernsDriver($changes)) { + static::notifyDriver($order, $event); + } + + return $event; + } + + /** + * Whether any of the changes concerns the driver doing the order. + */ + public static function concernsDriver(array $changes): bool + { + return (bool) array_intersect($changes, self::DRIVER_CHANGES); + } + + /** + * Push the change to the order's assigned driver. + */ + public static function notifyDriver(Order $order, self $event): bool + { + if (!$order->driver_assigned_uuid) { + return false; + } + + try { + $driver = static::assignedDriver($order); + if (!$driver) { + return false; + } + + $driver->notify(new OrderChangedNotification($order, $event->data['changes'], (string) $event->data['summary'])); + + return true; + } catch (\Throwable $exception) { + Log::warning('Unable to notify driver of order change', ['order' => $order->public_id, 'error' => $exception->getMessage()]); + + return false; + } + } + + /** + * The order's assigned driver, read past the driver scope as the order listeners do. + */ + public static function assignedDriver(Order $order): ?Driver + { + if (!$order->driver_assigned_uuid) { + return null; + } + + $driver = $order->relationLoaded('driverAssigned') ? $order->getRelation('driverAssigned') : null; + if ($driver instanceof Driver && $driver->uuid === $order->driver_assigned_uuid) { + return $driver; + } + + $driver = Driver::where('uuid', $order->driver_assigned_uuid)->withoutGlobalScopes()->first(); + if ($driver) { + $order->setRelation('driverAssigned', $driver); + } + + return $driver; + } + + public static function channelNamesFor(Order $order): array + { + $channels = [ + 'order.' . $order->public_id, + 'order.' . $order->uuid, + ]; + + if ($order->driver_assigned_uuid) { + $driverPublicId = static::attempt(fn () => static::assignedDriver($order)?->public_id); + if ($driverPublicId) { + $channels[] = 'driver.' . $driverPublicId; + } + $channels[] = 'driver.' . $order->driver_assigned_uuid; + } + + if ($order->customer_uuid) { + $customerPublicId = data_get($order, 'customer.public_id'); + if ($customerPublicId) { + $channels[] = 'customer.' . $customerPublicId; + } + } + + if ($order->company_uuid) { + $channels[] = 'company.' . $order->company_uuid; + } + + return array_values(array_unique($channels)); + } + + public static function dataFor(Order $order, array $changes, string $summary = '', array $extra = []): array + { + $payload = $order->relationLoaded('payload') ? $order->payload : static::attempt(fn () => $order->payload); + + return [ + 'id' => $order->public_id, + 'changes' => array_values($changes), + 'summary' => $summary, + 'dropoff' => array_key_exists('dropoff', $extra) ? $extra['dropoff'] : static::placeData($payload?->dropoff ?? null), + 'cod_amount' => array_key_exists('cod_amount', $extra) ? $extra['cod_amount'] : static::numberOrNull($payload?->cod_amount ?? null), + 'total' => array_key_exists('total', $extra) ? $extra['total'] : static::numberOrNull(static::attempt(fn () => $order->transaction_amount)), + 'currency' => array_key_exists('currency', $extra) ? $extra['currency'] : (static::attempt(fn () => $order->transaction_currency) ?? $payload?->cod_currency ?? null), + 'changed_by' => array_key_exists('changed_by', $extra) ? $extra['changed_by'] : static::currentUserName(), + 'changed_at' => $extra['changed_at'] ?? Carbon::now()->toIso8601ZuluString(), + ]; + } + + /** + * The drop-off as `{ id, address, location: { type: Point, coordinates: [lng, lat] } }`. + */ + public static function placeData(?Place $place): ?array + { + if (!$place instanceof Place) { + return null; + } + + $location = $place->location; + + return [ + 'id' => $place->public_id, + 'address' => static::attempt(fn () => $place->address), + 'location' => $location instanceof Point ? ['type' => 'Point', 'coordinates' => [$location->getLng(), $location->getLat()]] : null, + ]; + } + + public function broadcastOn(): array + { + return array_map(fn (string $name) => new Channel($name), $this->channelNames); + } + + public function broadcastAs(): string + { + return 'order.changed'; + } + + public function broadcastWith(): array + { + return [ + 'id' => $this->eventId, + 'api_version' => config('api.version'), + 'event' => $this->broadcastAs(), + 'created_at' => $this->sentAt, + 'data' => $this->data, + ]; + } + + protected static function currentUserName(): ?string + { + $userUuid = session('user'); + if (!$userUuid) { + return null; + } + + return static::attempt(fn () => User::where('uuid', $userUuid)->value('name')); + } + + protected static function numberOrNull(mixed $value): int|float|null + { + if ($value === null || $value === '' || !is_numeric($value)) { + return null; + } + + return $value + 0; + } + + protected static function attempt(callable $callback): mixed + { + try { + return $callback(); + } catch (\Throwable) { + return null; + } + } +} diff --git a/server/src/Exceptions/OrderEditLockedException.php b/server/src/Exceptions/OrderEditLockedException.php new file mode 100644 index 000000000..1c5c3bbc9 --- /dev/null +++ b/server/src/Exceptions/OrderEditLockedException.php @@ -0,0 +1,32 @@ + $locked the refused fields and why each is locked + */ + public function __construct(public array $locked = [], string $message = '', int $code = 422, ?\Throwable $previous = null) + { + if ($message === '') { + $message = $locked ? implode(' ', array_values(array_unique($locked))) : 'This order can no longer be edited.'; + } + + parent::__construct($message, $code, $previous); + } + + /** + * The 422 response body: the message plus the refused fields. + */ + public function toResponseData(): array + { + return ['locked' => $this->locked]; + } +} diff --git a/server/src/Http/Controllers/Api/v1/EntityController.php b/server/src/Http/Controllers/Api/v1/EntityController.php index bc8203920..70cddb35c 100644 --- a/server/src/Http/Controllers/Api/v1/EntityController.php +++ b/server/src/Http/Controllers/Api/v1/EntityController.php @@ -2,13 +2,17 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1; +use Fleetbase\FleetOps\Exceptions\OrderEditLockedException; use Fleetbase\FleetOps\Http\Requests\CreateEntityRequest; use Fleetbase\FleetOps\Http\Requests\UpdateEntityRequest; use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource; use Fleetbase\FleetOps\Http\Resources\v1\Entity as EntityResource; use Fleetbase\FleetOps\Models\Entity; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; +use Fleetbase\FleetOps\Support\OrderEdit; +use Fleetbase\FleetOps\Support\OrderEditPolicy; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Illuminate\Http\Request; @@ -79,8 +83,15 @@ public function create(CreateEntityRequest $request) // make sure company is set $input['company_uuid'] = session('company'); + // adding an item to an order is refused once the order's status locks its items + $edit = $this->orderEditForPayload($input['payload_uuid'] ?? null); + if ($locked = $this->guardItemsEdit($edit, true)) { + return $locked; + } + // create the entity $entity = $this->createEntity($input); + $this->commitItemsEdit($edit); // response the driver resource return $this->entityResource($entity); @@ -159,8 +170,15 @@ public function update($id, UpdateEntityRequest $request) } } + // changing an order's item is refused once the order's status locks its items + $edit = $this->orderEditForPayload($entity->payload_uuid); + if ($locked = $this->guardItemsEdit($edit, true)) { + return $locked; + } + // update the entity $entity->update($input); + $this->commitItemsEdit($edit); // response the entity resource return $this->entityResource($entity); @@ -220,13 +238,61 @@ public function delete($id, Request $request) ); } + // removing an order's item is refused once the order's status locks its items + $edit = $this->orderEditForPayload($entity->payload_uuid); + if ($locked = $this->guardItemsEdit($edit, false)) { + return $locked; + } + // delete the entity $entity->delete(); + $this->commitItemsEdit($edit); // response the entity resource return $this->deletedEntityResource($entity); } + /** + * Begin an edit of the order the payload belongs to, if it belongs to one. + */ + protected function orderEditForPayload(?string $payloadUuid): ?OrderEdit + { + if (!$payloadUuid) { + return null; + } + + try { + $order = Order::where('payload_uuid', $payloadUuid)->first(); + } catch (\Throwable) { + return null; + } + + return $order ? OrderEdit::begin($order) : null; + } + + /** + * A 422 response when the order's status locks the item change, otherwise null. + */ + protected function guardItemsEdit(?OrderEdit $edit, bool $adds) + { + if (!$edit) { + return null; + } + + try { + $edit->guard([OrderEditPolicy::FIELD_ITEMS => ['adds' => $adds]]); + } catch (OrderEditLockedException $exception) { + return response()->apiError($exception->getMessage(), 422, $exception->toResponseData()); + } + + return null; + } + + protected function commitItemsEdit(?OrderEdit $edit): void + { + $edit?->commit(); + } + protected function entityInputFromRequest(Request $request): array { return $request->only([ diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 8337cd0b3..4f4d059da 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Events\OrderDispatchFailed; use Fleetbase\FleetOps\Events\OrderStarted; use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException; +use Fleetbase\FleetOps\Exceptions\OrderEditLockedException; use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; use Fleetbase\FleetOps\Flow\Activity; use Fleetbase\FleetOps\Http\Requests\CreateOrderRequest; @@ -24,6 +25,7 @@ use Fleetbase\FleetOps\Models\Proof; use Fleetbase\FleetOps\Models\ServiceQuote; use Fleetbase\FleetOps\Models\Waypoint; +use Fleetbase\FleetOps\Support\OrderEdit; use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops; use Fleetbase\FleetOps\Support\TrackingCode; use Fleetbase\FleetOps\Support\Utils; @@ -353,6 +355,19 @@ public function update($id, UpdateOrderRequest $request) // get request input $input = $this->orderUpdateInputFromRequest($request); + // `entities_mode=replace` makes the entities sent the payload's full list + // (update by id, create new, delete missing); by default they are appended + $entitiesMode = OrderEdit::entitiesMode($request->input('entities_mode', $request->input('payload.entities_mode'))); + $codAmount = $this->codAmountFromRequest($request); + + // refuse changes the order's status locks before anything is written + $edit = $this->beginOrderEdit($order); + try { + $edit->guard($edit->requestedChanges($this->orderEditInputFromRequest($request, $codAmount), $entitiesMode)); + } catch (OrderEditLockedException $exception) { + return $this->lockedEditResponse($exception); + } + // update payload if new input or change payload by id if ($request->isArray('payload')) { $payload = data_get($order, 'payload', $this->newPayload()); @@ -384,6 +399,10 @@ public function update($id, UpdateOrderRequest $request) $payload->setReturn($return); } + if ($codAmount !== null) { + $payload->cod_amount = $codAmount; + } + $payload->save(); // set waypoints and entities after payload is saved @@ -393,14 +412,10 @@ public function update($id, UpdateOrderRequest $request) $payload->removeWaypoints(); } - if ($entities) { - $payload->setEntities($entities); - } + $this->applyPayloadEntities($payload, $entities, $entitiesMode); - $firstWaypoint = $payload->getPickupOrFirstWaypoint(); - if ($firstWaypoint instanceof Place) { - $payload->setCurrentWaypoint($firstWaypoint); - } + // the current waypoint is reconciled once the edit is committed: it is only + // moved when the route changed, and an order under way keeps its stop $input['payload_uuid'] = $payload->uuid; } elseif ($request->has('payload')) { @@ -409,6 +424,11 @@ public function update($id, UpdateOrderRequest $request) 'company_uuid' => $this->sessionCompany(), ]); unset($input['payload']); + + if ($codAmount !== null && is_string($input['payload_uuid']) && ($switchedPayload = $this->findPayloadByUuid($input['payload_uuid']))) { + $switchedPayload->cod_amount = $codAmount; + $switchedPayload->save(); + } } // create a payload if missing payload[] but has pickup/dropoff/etc @@ -442,6 +462,10 @@ public function update($id, UpdateOrderRequest $request) $payload->setReturn($return); } + if ($codAmount !== null) { + $payload->cod_amount = $codAmount; + } + $payload->save(); // set waypoints and entities after payload is saved @@ -451,14 +475,10 @@ public function update($id, UpdateOrderRequest $request) $payload->removeWaypoints(); } - if ($entities) { - $payload->setEntities($entities); - } + $this->applyPayloadEntities($payload, $entities, $entitiesMode); - $firstWaypoint = $payload->getPickupOrFirstWaypoint(); - if ($firstWaypoint instanceof Place) { - $payload->setCurrentWaypoint($firstWaypoint); - } + // the current waypoint is reconciled once the edit is committed: it is only + // moved when the route changed, and an order under way keeps its stop $input['payload_uuid'] = $payload->uuid; } @@ -538,6 +558,10 @@ public function update($id, UpdateOrderRequest $request) $order->update($input); $order->flushAttributesCache(); + // reroute, recompute distance and time, republish tracking and announce + // `order.changed` for whatever this update changed + $this->commitOrderEdit($edit); + // load required relations $order->load(['trackingNumber', 'trackingStatuses', 'driverAssigned', 'vehicleAssigned', 'purchaseRate.serviceQuote.items', 'customer', 'facilitator']); @@ -545,6 +569,79 @@ public function update($id, UpdateOrderRequest $request) return $this->orderResource($order); } + /** + * The cash to collect sent with an order update, as `payload.cod_amount` or a + * top-level `cod_amount`. Zero is a valid amount; anything not numeric is ignored. + */ + protected function codAmountFromRequest(Request $request): int|float|string|null + { + $value = null; + if ($request->isArray('payload') && Arr::has((array) $request->input('payload'), 'cod_amount')) { + $value = data_get($request->input('payload'), 'cod_amount'); + } elseif ($request->has('cod_amount')) { + $value = $request->input('cod_amount'); + } + + return is_numeric($value) ? $value : null; + } + + /** + * The parts of an order update the edit policy looks at. + */ + protected function orderEditInputFromRequest(Request $request, mixed $codAmount = null): array + { + $routeKeys = ['pickup', 'dropoff', 'return', 'waypoints', 'entities']; + + if ($request->isArray('payload')) { + $edit = Arr::only((array) $request->input('payload'), $routeKeys); + } elseif ($request->has('payload')) { + // switching to another payload replaces the whole route + $edit = ['pickup' => true, 'dropoff' => true, 'waypoints' => true]; + } else { + $edit = $request->only($routeKeys); + } + + foreach (['scheduled_at', 'notes', 'customer'] as $key) { + if ($request->has($key)) { + $edit[$key] = $request->input($key); + } + } + + if ($codAmount !== null) { + $edit['cod_amount'] = $codAmount; + } + + return $edit; + } + + protected function applyPayloadEntities(Payload $payload, mixed $entities, string $mode): void + { + if ($mode === OrderEdit::ENTITIES_REPLACE && is_array($entities)) { + $payload->syncEntities($entities); + + return; + } + + if ($entities) { + $payload->setEntities($entities); + } + } + + protected function beginOrderEdit(Order $order): OrderEdit + { + return OrderEdit::begin($order); + } + + protected function commitOrderEdit(OrderEdit $edit): array + { + return $edit->commit(); + } + + protected function lockedEditResponse(OrderEditLockedException $exception) + { + return response()->apiError($exception->getMessage(), 422, $exception->toResponseData()); + } + protected function orderCreateInputFromRequest(Request $request): array { return $request->only([ diff --git a/server/src/Http/Controllers/Api/v1/PayloadController.php b/server/src/Http/Controllers/Api/v1/PayloadController.php index 59ae1ad03..3e0c08016 100644 --- a/server/src/Http/Controllers/Api/v1/PayloadController.php +++ b/server/src/Http/Controllers/Api/v1/PayloadController.php @@ -2,12 +2,15 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1; +use Fleetbase\FleetOps\Exceptions\OrderEditLockedException; use Fleetbase\FleetOps\Http\Requests\CreatePayloadRequest; use Fleetbase\FleetOps\Http\Requests\UpdatePayloadRequest; use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource; use Fleetbase\FleetOps\Http\Resources\v1\Payload as PayloadResource; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; +use Fleetbase\FleetOps\Support\OrderEdit; use Fleetbase\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -103,6 +106,18 @@ public function update($id, UpdatePayloadRequest $request) $return = $routeShape['return']; $hasWaypointsField = $routeShape['has_waypoints_field']; $hasRouteEndpointFields = $routeShape['has_route_endpoint_fields']; + $entitiesMode = OrderEdit::entitiesMode($input['entities_mode'] ?? null); + + // when the payload belongs to an order, refuse changes the order's status locks + $order = $this->orderForPayload($payload); + $edit = $order ? $this->beginOrderEdit($order) : null; + if ($edit) { + try { + $edit->guard($edit->requestedChanges(Arr::only($input, ['pickup', 'dropoff', 'return', 'waypoints', 'entities', 'cod_amount']), $entitiesMode)); + } catch (OrderEditLockedException $exception) { + return $this->lockedEditResponse($exception); + } + } // pickup assignment if ($pickup) { @@ -126,21 +141,29 @@ public function update($id, UpdatePayloadRequest $request) $payload->removeWaypoints(); } - // set entities - if ($entities) { + // set entities: appended, or with `entities_mode=replace` the full list + if ($entitiesMode === OrderEdit::ENTITIES_REPLACE && is_array($entities)) { + $payload->syncEntities($entities); + } elseif ($entities) { $payload->setEntities($entities); } - // update the payload - $payload->fill(array_filter($this->payloadFillInputFromInput($input))); + // update the payload; empty values are skipped, but a cash-to-collect of 0 is kept + $payload->fill($this->payloadUpdateFillInput($input)); // save the payload $payload->save(); - // set the first / current waypoint from the effective route shape - $firstWaypoint = $payload->getPickupOrFirstWaypoint(); - if ($firstWaypoint instanceof Place) { - $payload->setCurrentWaypoint($firstWaypoint); + if ($edit) { + // the order's edit moves the current waypoint only when the route changed, keeps + // an order under way on its stop and announces the change + $this->commitOrderEdit($edit); + } else { + // set the first / current waypoint from the effective route shape + $firstWaypoint = $payload->getPickupOrFirstWaypoint(); + if ($firstWaypoint instanceof Place) { + $payload->setCurrentWaypoint($firstWaypoint); + } } // make sure entities and waypoints is loaded @@ -237,6 +260,51 @@ protected function payloadFillInputFromInput(array $input): array return Arr::only($input, ['type', 'provider', 'meta', 'cod_amount', 'cod_currency', 'cod_payment_method']); } + /** + * The payload attributes an update fills: empty values are skipped as before, + * except a numeric cash-to-collect, so 0 clears it. + */ + protected function payloadUpdateFillInput(array $input): array + { + $fill = $this->payloadFillInputFromInput($input); + $cod = $fill['cod_amount'] ?? null; + $fill = array_filter($fill); + + if (is_numeric($cod)) { + $fill['cod_amount'] = $cod; + } + + return $fill; + } + + protected function orderForPayload(Payload $payload): ?Order + { + if (!$payload->uuid) { + return null; + } + + try { + return Order::where('payload_uuid', $payload->uuid)->first(); + } catch (\Throwable) { + return null; + } + } + + protected function beginOrderEdit(Order $order): OrderEdit + { + return OrderEdit::begin($order); + } + + protected function commitOrderEdit(OrderEdit $edit): array + { + return $edit->commit(); + } + + protected function lockedEditResponse(OrderEditLockedException $exception) + { + return response()->apiError($exception->getMessage(), 422, $exception->toResponseData()); + } + protected function newPayload(array $input): Payload { return new Payload($input); diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 169c815cf..69a78a52f 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -10,6 +10,7 @@ use Fleetbase\FleetOps\Events\OrderStarted; use Fleetbase\FleetOps\Events\WaypointActivityChanged; use Fleetbase\FleetOps\Events\WaypointCompleted; +use Fleetbase\FleetOps\Exceptions\OrderEditLockedException; use Fleetbase\FleetOps\Exports\OrderExport; use Fleetbase\FleetOps\Flow\Activity; use Fleetbase\FleetOps\Http\Controllers\FleetOpsController; @@ -34,6 +35,7 @@ use Fleetbase\FleetOps\Models\TrackingStatus; use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\FleetOps\Notifications\OrderPing; +use Fleetbase\FleetOps\Support\OrderEdit; use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Requests\ExportRequest; @@ -320,6 +322,19 @@ public function editOrderRoute(string $id, Request $request) return $this->errorResponse('Unable to find order to update route for.'); } + // Refuse a route edit the order's status locks (completed, canceled, expired) + $edit = $this->beginOrderEdit($order); + try { + $edit->guard($edit->requestedChanges(array_filter([ + 'pickup' => $hasPickupInput ? true : null, + 'dropoff' => $hasDropoffInput ? true : null, + 'return' => $hasReturnInput ? true : null, + 'waypoints' => ($hasWaypointsInput || $hasPickupInput || $hasDropoffInput || $hasReturnInput) ? true : null, + ]))); + } catch (OrderEditLockedException $exception) { + return $this->errorResponse($exception->getMessage(), 422, $exception->toResponseData()); + } + if ($hasPickupInput) { if ($pickup) { $order->payload->setPickup($pickup, ['save' => true]); @@ -354,14 +369,9 @@ public function editOrderRoute(string $id, Request $request) $order->payload->removeWaypoints(); } - $startingDestination = $order->payload->getPickupOrFirstWaypoint(); - if (!$startingDestination) { - $startingDestination = $order->payload->getDropoffOrLastWaypoint(); - } - - if ($startingDestination instanceof Place || $startingDestination instanceof Waypoint) { - $order->payload->setCurrentWaypoint($startingDestination); - } + // Only a changed route moves the current waypoint, and an order under way keeps + // the driver's stop; distance, time, tracking and `order.changed` follow the change + $this->commitOrderEdit($edit); $order->load(['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints']); @@ -794,9 +804,19 @@ protected function jsonResponse(array $payload) return response()->json($payload); } - protected function errorResponse(string $message, int $status = 400) + protected function errorResponse(string $message, int $status = 400, array $data = []) + { + return response()->error($message, $status, $data); + } + + protected function beginOrderEdit(Order $order): OrderEdit + { + return OrderEdit::begin($order); + } + + protected function commitOrderEdit(OrderEdit $edit): array { - return response()->error($message, $status); + return $edit->commit(); } /** diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index f2e306ae5..001ff9265 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -10,6 +10,7 @@ use Fleetbase\FleetOps\Events\OrderDriverAssigned; use Fleetbase\FleetOps\Flow\Activity; use Fleetbase\FleetOps\Support\LabelPdf; +use Fleetbase\FleetOps\Support\OrderEditPolicy; use Fleetbase\FleetOps\Support\OrderTracker; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\FleetOps\Traits\HasTrackingNumber; @@ -1461,6 +1462,14 @@ public function notifyDriverAssigned() } } + /** + * The edit policy for the order at its current status. + */ + public function editPolicy(): OrderEditPolicy + { + return OrderEditPolicy::for($this); + } + /** * Updates the activity of the order. * Updates the order status and inserts a new activity based on the provided activity details. diff --git a/server/src/Models/Payload.php b/server/src/Models/Payload.php index 1ee94a0f9..c0461ce6f 100644 --- a/server/src/Models/Payload.php +++ b/server/src/Models/Payload.php @@ -21,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -251,6 +252,11 @@ public function setCodAmountAttribute($value) $this->attributes['cod_amount'] = Utils::numbersOnly($value); } + /** + * Add entities to the payload. Every entry creates a new entity, so sending + * the payload's current entities again duplicates them; use syncEntities() + * to replace the list. + */ public function setEntities($entities = []) { if (empty($entities) || !is_array($entities)) { @@ -258,49 +264,228 @@ public function setEntities($entities = []) } foreach ($entities as $attributes) { - if (isset($attributes['_import_id'])) { - $waypoint = $this->waypoints->firstWhere('_import_id', $attributes['_import_id']); + $entity = new Entity($this->prepareEntityAttributes($attributes)); + $this->entities()->save($entity); + } - if ($waypoint) { - $attributes['destination_uuid'] = $waypoint->uuid; - } + return $this; + } + + /** + * Replace the payload's entities with the given list: entries naming an + * existing entity of this payload (by `id` public id or `uuid`) update it, + * entries without one create a new entity, and entities left out are deleted. + * + * @return array{created: string[], updated: string[], deleted: string[]} entity uuids by outcome + */ + public function syncEntities($entities = []): array + { + $result = ['created' => [], 'updated' => [], 'deleted' => []]; + if (!is_array($entities)) { + return $result; + } + + $existing = $this->entities()->get(); + $kept = []; + + foreach ($entities as $attributes) { + if (!is_array($attributes)) { + continue; } - // if a destination or waypoint is explicitly set - if (empty($attributes['destination_uuid'])) { - $destinationKey = Utils::or($attributes, ['waypoint', 'destination']); - $destination = $this->findDestinationFromKey($destinationKey); - if ($destination instanceof Place) { - $attributes['destination_uuid'] = $destination->uuid; + $entity = $this->findOwnEntity($existing, $attributes); + $values = $this->prepareEntityAttributes(Arr::except($attributes, ['id', 'uuid', 'public_id', 'payload_uuid', 'company_uuid'])); + + if ($entity) { + $entity->fill(Arr::only($values, $entity->getFillable())); + if ($entity->isDirty()) { + $entity->save(); + $result['updated'][] = $entity->uuid; } + $kept[] = $entity->uuid; + + continue; } - // Validate destination actually exists - if (isset($attributes['destination_uuid']) && Place::where('uuid', $attributes['destination_uuid'])->doesntExist()) { - $validDestination = $this->_findCorrectDestinationForEntity($attributes); - if ($validDestination) { - $attributes['destination_uuid'] = $validDestination->uuid; - } + $entity = new Entity($values); + $this->entities()->save($entity); + $result['created'][] = $entity->uuid; + $kept[] = $entity->uuid; + } + + foreach ($existing as $entity) { + if (!in_array($entity->uuid, $kept, true)) { + $entity->delete(); + $result['deleted'][] = $entity->uuid; } + } - // Handle entity photo upload - if (isset($attributes['photo'])) { - $path = 'uploads/' . session('company') . '/entities'; - $file = app(\Fleetbase\Services\FileResolverService::class)->resolve($attributes['photo'], $path); + $this->unsetRelation('entities'); - if ($file) { - $attributes['photo_uuid'] = $file->uuid; + return $result; + } + + /** + * Whether every entry names an entity this payload already has, i.e. applying the + * list with syncEntities() would only update or remove entities, never add one. + */ + public function entitiesListOnlyNamesExisting(array $entities): bool + { + $existing = $this->entities()->get(); + + foreach ($entities as $attributes) { + if (!is_array($attributes) || !$this->findOwnEntity($existing, $attributes)) { + return false; + } + } + + return true; + } + + /** + * Find the payload entity an entity entry refers to by `id` (public id) or `uuid`. + */ + protected function findOwnEntity($existing, array $attributes): ?Entity + { + $key = $attributes['id'] ?? $attributes['public_id'] ?? $attributes['uuid'] ?? null; + if (!is_string($key) || $key === '') { + return null; + } + + return $existing->first(fn (Entity $entity) => $entity->public_id === $key || $entity->uuid === $key); + } + + /** + * Resolve an entity entry's destination and photo into storable attributes. + */ + protected function prepareEntityAttributes(array $attributes): array + { + if (isset($attributes['_import_id'])) { + $waypoint = $this->waypoints->firstWhere('_import_id', $attributes['_import_id']); + + if ($waypoint) { + $attributes['destination_uuid'] = $waypoint->uuid; + } + } + + // if a destination or waypoint is explicitly set + if (empty($attributes['destination_uuid'])) { + $destinationKey = Utils::or($attributes, ['waypoint', 'destination']); + $destination = $this->findDestinationFromKey($destinationKey); + if ($destination instanceof Place) { + $attributes['destination_uuid'] = $destination->uuid; + } + } + + // Validate destination actually exists + if (isset($attributes['destination_uuid']) && Place::where('uuid', $attributes['destination_uuid'])->doesntExist()) { + $validDestination = $this->_findCorrectDestinationForEntity($attributes); + if ($validDestination) { + $attributes['destination_uuid'] = $validDestination->uuid; + } + } + + // Handle entity photo upload + if (isset($attributes['photo'])) { + $path = 'uploads/' . session('company') . '/entities'; + $file = app(\Fleetbase\Services\FileResolverService::class)->resolve($attributes['photo'], $path); + + if ($file) { + $attributes['photo_uuid'] = $file->uuid; + } + + // Clean up raw photo data + unset($attributes['photo']); + } + + return $attributes; + } + + /** + * The payload's stops as stored: pickup, dropoff and return place uuids, the + * waypoint place uuids in order and the current waypoint. Compare two + * signatures with routeChanges() to see what an edit changed. + */ + public function routeSignature(): array + { + return [ + 'payload' => $this->uuid, + 'pickup' => $this->pickup_uuid, + 'dropoff' => $this->dropoff_uuid, + 'return' => $this->return_uuid, + 'waypoints' => $this->uuid ? Waypoint::where('payload_uuid', $this->uuid)->whereNull('deleted_at')->orderBy('order')->pluck('place_uuid')->values()->all() : [], + 'current' => $this->current_waypoint_uuid, + ]; + } + + /** + * The route parts (pickup, dropoff, return, waypoints) that differ between two route signatures. + */ + public static function routeChanges(array $before, array $after): array + { + $changes = []; + foreach (['pickup', 'dropoff', 'return', 'waypoints'] as $part) { + if (($before[$part] ?? null) !== ($after[$part] ?? null)) { + $changes[] = $part; + } + } + + return $changes; + } + + /** + * Point the payload at the right stop after its route changed. + * + * Before the order is under way this is the first stop, as it always was. Once + * the driver is under way the stop they are heading to is kept when it is still + * on the route; when it was replaced the stop that took its place is used (the + * new pickup, dropoff or return), and a removed waypoint moves them on to the + * next stop they have not completed. + * + * @param array $before the routeSignature() taken before the edit + */ + public function reconcileCurrentWaypoint(array $before, bool $inProgress): ?Place + { + $this->unsetRelation('waypoints'); + $this->unsetRelation('waypointMarkers'); + $this->loadMissing(['pickup', 'dropoff', 'return']); + + $destination = null; + + if ($inProgress) { + $after = $this->routeSignature(); + $current = $before['current'] ?? null; + $stops = array_filter(array_merge([$after['pickup'], $after['dropoff'], $after['return']], $after['waypoints'])); + + if ($current && in_array($current, $stops, true)) { + $destination = Place::where('uuid', $current)->first(); + } elseif ($current) { + foreach (['pickup', 'dropoff', 'return'] as $role) { + if ($current === ($before[$role] ?? null) && $this->{$role} instanceof Place) { + $destination = $this->{$role}; + break; + } } - // Clean up raw photo data - unset($attributes['photo']); + if (!$destination) { + $next = $this->waypointMarkers()->orderBy('order')->get()->first(function (Waypoint $waypoint) { + return !$waypoint->complete; + }); + $destination = $next ? $next->getPlace() : ($this->dropoff instanceof Place ? $this->dropoff : null); + } } + } - $entity = new Entity($attributes); - $this->entities()->save($entity); + if (!$destination) { + $destination = $this->getPickupOrFirstWaypoint() ?? $this->getDropoffOrLastWaypoint(); } - return $this; + if ($destination instanceof Place) { + $this->setCurrentWaypoint($destination); + $this->setRelation('currentWaypoint', $destination); + } + + return $destination instanceof Place ? $destination : null; } public function insertEntities($entities = []) diff --git a/server/src/Notifications/OrderChanged.php b/server/src/Notifications/OrderChanged.php new file mode 100644 index 000000000..6db50bccc --- /dev/null +++ b/server/src/Notifications/OrderChanged.php @@ -0,0 +1,87 @@ +order = $order; + $this->changes = array_values($changes); + $this->title = 'Order ' . (data_get($order, 'trackingNumber.tracking_number') ?? $order->public_id) . ' changed'; + $this->message = $summary !== '' ? $summary : 'This order was changed, tap for details.'; + $this->data = ['type' => 'order_changed', 'id' => $order->public_id, 'changes' => $this->changes]; + } + + public function via($notifiable): array + { + return ['broadcast', FcmChannel::class, ApnChannel::class]; + } + + public function broadcastOn(): array + { + return [ + new Channel('driver.' . data_get($this->order, 'driver_assigned_uuid')), + new Channel('driver.' . data_get($this->order, 'driverAssigned.public_id')), + ]; + } + + public function toArray($notifiable = null): array + { + return [ + 'event' => 'order.changed_notification', + 'title' => $this->title, + 'body' => $this->message, + 'data' => $this->data, + ]; + } + + public function toFcm($notifiable) + { + return PushNotification::createFcmMessage($this->title, $this->message, $this->fcmData()); + } + + public function toApn($notifiable) + { + return PushNotification::createApnMessage($this->title, $this->message, $this->data, 'view_order'); + } + + /** + * FCM data values must be strings, so on FCM the change list travels comma + * separated ("dropoff,items"); APNs and the broadcast carry it as a list. + */ + public function fcmData(): array + { + return array_merge($this->data, ['changes' => implode(',', $this->changes)]); + } +} diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index 32ddd9bdc..a60f5de5f 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -252,6 +252,7 @@ public function registerNotifications() \Fleetbase\FleetOps\Notifications\LateDeparture::class, \Fleetbase\FleetOps\Notifications\RouteDeviation::class, \Fleetbase\FleetOps\Notifications\ProlongedStoppage::class, + \Fleetbase\FleetOps\Notifications\OrderChanged::class, ]); // Register Notifiables diff --git a/server/src/Support/OrderEdit.php b/server/src/Support/OrderEdit.php new file mode 100644 index 000000000..d0ca5c13d --- /dev/null +++ b/server/src/Support/OrderEdit.php @@ -0,0 +1,365 @@ +guard($edit->requestedChanges($input)); + * // ...apply the edit... + * $edit->commit(); + */ +class OrderEdit +{ + public const ENTITIES_APPEND = 'append'; + public const ENTITIES_REPLACE = 'replace'; + + protected OrderEditPolicy $policy; + + protected ?array $routeBefore = null; + + protected array $entitiesBefore = []; + + protected mixed $codBefore = null; + + protected ?string $scheduledBefore = null; + + protected ?string $notesBefore = null; + + protected ?string $customerBefore = null; + + protected array $marked = []; + + public function __construct(public Order $order) + { + $this->policy = OrderEditPolicy::for($order); + + $payload = $this->currentPayload(); + if ($payload instanceof Payload) { + $this->routeBefore = $payload->routeSignature(); + $this->entitiesBefore = $this->entitySnapshot($payload); + $this->codBefore = $payload->cod_amount; + } + + $this->scheduledBefore = static::timeString($order->scheduled_at); + $this->notesBefore = $order->notes; + $this->customerBefore = $order->customer_uuid; + } + + public static function begin(Order $order): static + { + return new static($order); + } + + public function policy(): OrderEditPolicy + { + return $this->policy; + } + + /** + * Normalize an entities mode from a request value: `replace` (or `sync`) replaces + * the list, anything else appends as the API always has. + */ + public static function entitiesMode(mixed $value): string + { + return in_array(strtolower((string) $value), ['replace', 'sync'], true) ? self::ENTITIES_REPLACE : self::ENTITIES_APPEND; + } + + /** + * The policy fields an edit request touches, with the context the policy checks. + * + * Keys read from $input (presence matters): pickup, dropoff, return, waypoints, + * entities, scheduled_at, notes, cod_amount, customer. + * + * @return array + */ + public function requestedChanges(array $input, string $entitiesMode = self::ENTITIES_APPEND): array + { + $changes = []; + + foreach (OrderEditPolicy::ROUTE_FIELDS as $field) { + if (array_key_exists($field, $input)) { + $changes[$field] = true; + } + } + + $entities = $input['entities'] ?? null; + if (is_array($entities) && ($entities !== [] || $entitiesMode === self::ENTITIES_REPLACE)) { + $payload = $this->currentPayload(); + $adds = true; + if ($entitiesMode === self::ENTITIES_REPLACE && $payload instanceof Payload) { + $adds = !$payload->entitiesListOnlyNamesExisting($entities); + } + $changes[OrderEditPolicy::FIELD_ITEMS] = ['adds' => $adds]; + } + + if (array_key_exists('scheduled_at', $input) && static::timeString($input['scheduled_at']) !== $this->scheduledBefore) { + $changes[OrderEditPolicy::FIELD_SCHEDULE] = ['from' => $this->scheduledBefore, 'to' => $input['scheduled_at']]; + } + + if (array_key_exists('notes', $input) && (string) $input['notes'] !== (string) $this->notesBefore) { + $changes[OrderEditPolicy::FIELD_NOTES] = true; + } + + if (array_key_exists('cod_amount', $input) && !static::sameAmount($input['cod_amount'], $this->codBefore)) { + $changes[OrderEditPolicy::FIELD_COD_AMOUNT] = true; + } + + if (array_key_exists('customer', $input) && !empty($input['customer']) && !$this->isCurrentCustomer($input['customer'])) { + $changes[OrderEditPolicy::FIELD_CONTACT] = true; + } + + return $changes; + } + + /** + * Refuse the edit when its status locks any requested field. + * + * @throws \Fleetbase\FleetOps\Exceptions\OrderEditLockedException + */ + public function guard(array $changes): static + { + $this->policy->assert($changes); + + return $this; + } + + /** + * Record a change the snapshot cannot see (for example a total recomputed elsewhere). + */ + public function markChanged(string ...$changes): static + { + $this->marked = array_values(array_unique(array_merge($this->marked, $changes))); + + return $this; + } + + /** + * Work out what changed since begin(), apply the follow-up effects and announce it. + * + * @param array $extra order.changed data overrides (summary, total, currency, changed_by, ...) + * @param bool $notifyDriver push the change to the assigned driver when it concerns them + * + * @return string[] the changes announced + */ + public function commit(array $extra = [], bool $notifyDriver = true): array + { + $order = $this->order; + $payload = $this->freshPayload(); + $changes = []; + + $routeChanges = []; + if ($payload instanceof Payload) { + $routeAfter = $payload->routeSignature(); + $routeChanges = $this->routeBefore ? Payload::routeChanges($this->routeBefore, $routeAfter) : ['pickup', 'dropoff', 'waypoints']; + $routeChanges = array_values(array_filter($routeChanges, function ($part) use ($routeAfter) { + // A route part that was and still is empty did not change. + return !empty($this->routeBefore[$part] ?? null) || !empty($routeAfter[$part]); + })); + + if ($routeChanges) { + $payload->reconcileCurrentWaypoint($this->routeBefore ?? [], $this->isUnderWay()); + } + + if ($this->entitySnapshot($payload) !== $this->entitiesBefore) { + $changes[] = OrderEditPolicy::FIELD_ITEMS; + } + + if (!static::sameAmount($payload->cod_amount, $this->codBefore)) { + $changes[] = OrderEditPolicy::FIELD_COD_AMOUNT; + } + + $order->setRelation('payload', $payload); + } + + $changes = array_merge($routeChanges, $changes); + + if (static::timeString($order->scheduled_at) !== $this->scheduledBefore) { + $changes[] = OrderEditPolicy::FIELD_SCHEDULE; + } + + if ((string) $order->notes !== (string) $this->notesBefore) { + $changes[] = OrderEditPolicy::FIELD_NOTES; + } + + if ($order->customer_uuid !== $this->customerBefore) { + $changes[] = OrderEditPolicy::FIELD_CONTACT; + } + + $changes = array_values(array_unique(array_merge($changes, $this->marked))); + + if ($routeChanges) { + $this->afterRouteChanged(); + } + + if (in_array(OrderEditPolicy::FIELD_ITEMS, $changes, true) && $this->policy->flagsKitchenChanges()) { + $this->attempt(fn () => $order->updateMeta('kitchen_changed_at', Carbon::now()->toIso8601ZuluString())); + } + + if ($changes) { + $summary = $extra['summary'] ?? static::summarize($changes, $payload, $order); + unset($extra['summary']); + $this->announce($changes, (string) $summary, $extra, $notifyDriver); + } + + return $changes; + } + + /** + * One line describing the changes, e.g. "Drop-off changed to 8 Marina View · Items changed". + */ + public static function summarize(array $changes, ?Payload $payload = null, ?Order $order = null): string + { + $parts = []; + + foreach ($changes as $change) { + $parts[] = match ($change) { + 'dropoff' => static::placeLine('Drop-off', $payload?->dropoff), + 'pickup' => static::placeLine('Pickup', $payload?->pickup), + 'return' => static::placeLine('Return', $payload?->return), + 'waypoints' => 'Stops changed', + 'items' => 'Items changed', + 'schedule' => $order?->scheduled_at ? 'Rescheduled to ' . Carbon::parse($order->scheduled_at)->format('j M H:i') : 'Schedule cleared', + 'notes' => 'Notes updated', + 'cod_amount' => $payload && $payload->cod_amount !== null ? 'Cash to collect changed to ' . trim(($payload->cod_currency ?? '') . ' ' . $payload->cod_amount) : 'Cash to collect changed', + 'contact' => 'Customer changed', + 'total' => 'Total changed', + default => ucfirst(str_replace('_', ' ', (string) $change)) . ' changed', + }; + } + + return implode(' · ', array_filter($parts)); + } + + protected function afterRouteChanged(): void + { + $order = $this->order; + + $this->attempt(fn () => $order->setDistanceAndTime()); + $this->attempt(fn () => app(TrackingPublisher::class)->routeChanged($order)); + } + + protected function announce(array $changes, string $summary, array $extra, bool $notifyDriver): void + { + OrderChanged::dispatchFor($this->order, $changes, $summary, $extra, $notifyDriver); + } + + /** + * Whether the driver is already working the order, so a route change must keep their place. + */ + protected function isUnderWay(): bool + { + return (bool) $this->order->started || $this->policy->stage() === OrderEditPolicy::STAGE_IN_TRANSIT; + } + + protected function currentPayload(): ?Payload + { + $payload = $this->order->relationLoaded('payload') ? $this->order->getRelation('payload') : $this->attempt(fn () => $this->order->payload); + + return $payload instanceof Payload ? $payload : null; + } + + protected function freshPayload(): ?Payload + { + if (!$this->order->payload_uuid) { + return $this->currentPayload(); + } + + $payload = $this->attempt(fn () => Payload::where('uuid', $this->order->payload_uuid)->withoutGlobalScopes()->first()); + + return $payload instanceof Payload ? $payload : $this->currentPayload(); + } + + /** + * Entity uuid => fingerprint of its stored attributes. + */ + protected function entitySnapshot(Payload $payload): array + { + if (!$payload->uuid) { + return []; + } + + $entities = $this->attempt(fn () => Entity::where('payload_uuid', $payload->uuid)->get()) ?? collect(); + + return $entities->mapWithKeys(function (Entity $entity) { + $attributes = Arr::except($entity->getAttributes(), ['id', 'created_at', 'updated_at', 'qr_code', 'barcode']); + ksort($attributes); + + return [$entity->uuid => md5((string) json_encode($attributes))]; + })->sortKeys()->all(); + } + + protected function isCurrentCustomer(mixed $customer): bool + { + if (!is_string($customer)) { + return false; + } + + if ($customer === $this->customerBefore) { + return true; + } + + return $this->attempt(fn () => data_get($this->order, 'customer.public_id')) === $customer; + } + + protected static function placeLine(string $label, mixed $place): string + { + if (!$place instanceof Place) { + return $label . ' changed'; + } + + $address = trim((string) ($place->address ?: $place->name ?: $place->street1)); + + return $address !== '' ? $label . ' changed to ' . $address : $label . ' changed'; + } + + protected static function timeString(mixed $value): ?string + { + if (empty($value)) { + return null; + } + + try { + return Carbon::parse($value)->toIso8601ZuluString(); + } catch (\Throwable) { + return (string) $value; + } + } + + protected static function sameAmount(mixed $a, mixed $b): bool + { + $a = ($a === '' || $a === null) ? null : (is_numeric($a) ? (float) $a : $a); + $b = ($b === '' || $b === null) ? null : (is_numeric($b) ? (float) $b : $b); + + return $a === $b; + } + + protected function attempt(callable $callback): mixed + { + try { + return $callback(); + } catch (\Throwable $exception) { + Log::debug('Order edit follow-up failed', ['order' => $this->order->public_id, 'error' => $exception->getMessage()]); + + return null; + } + } +} diff --git a/server/src/Support/OrderEditPolicy.php b/server/src/Support/OrderEditPolicy.php new file mode 100644 index 000000000..c76d1e30d --- /dev/null +++ b/server/src/Support/OrderEditPolicy.php @@ -0,0 +1,292 @@ +order->status ?: 'created')); + } + + /** + * The edit stage of the order. + */ + public function stage(): string + { + if ($this->stage !== null) { + return $this->stage; + } + + $status = $this->status(); + + if ($this->isTerminal()) { + return $this->stage = self::STAGE_TERMINAL; + } + + if (in_array($status, self::READY_STATUSES, true)) { + return $this->stage = self::STAGE_READY; + } + + if ((bool) $this->order->started || in_array($status, self::IN_TRANSIT_STATUSES, true) || str_starts_with($status, 'driver_')) { + return $this->stage = self::STAGE_IN_TRANSIT; + } + + if (in_array($status, self::OPEN_STATUSES, true)) { + return $this->stage = self::STAGE_OPEN; + } + + // A custom status after dispatch means the order is out with a driver. + return $this->stage = (bool) $this->order->dispatched ? self::STAGE_IN_TRANSIT : self::STAGE_OPEN; + } + + /** + * Whether the order is completed, canceled or expired: completed meaning the + * order config's complete activity or one of its terminal activity codes. + */ + public function isTerminal(): bool + { + $status = $this->status(); + + if (in_array($status, self::TERMINAL_STATUSES, true)) { + return true; + } + + if (in_array($status, array_merge(self::OPEN_STATUSES, self::READY_STATUSES, self::IN_TRANSIT_STATUSES), true)) { + return false; + } + + $config = $this->orderConfig(); + if (!$config) { + return false; + } + + try { + if ($config->isTerminalActivityCode($status)) { + return true; + } + + $activity = $config->getActivityByCode($status); + + return $activity ? $activity->complete() : false; + } catch (\Throwable) { + return false; + } + } + + /** + * Fields locked outright at this stage, each with the reason. + * + * @return array + */ + public function locked(): array + { + $stage = $this->stage(); + + if ($stage === self::STAGE_TERMINAL) { + $reason = sprintf('The order is %s and can no longer be changed.', str_replace('_', ' ', $this->status())); + + return array_fill_keys(self::FIELDS, $reason); + } + + if ($stage === self::STAGE_IN_TRANSIT) { + return [ + self::FIELD_ITEMS => 'Items cannot be changed once a driver has the order.', + self::FIELD_SCHEDULE => 'The schedule cannot be changed once a driver has the order.', + ]; + } + + return []; + } + + /** + * Fields that are only partly editable at this stage. + * + * @return array field => removal_only|earlier_only + */ + public function restrictions(): array + { + if ($this->stage() === self::STAGE_READY) { + return [ + self::FIELD_ITEMS => self::RESTRICTION_REMOVAL_ONLY, + self::FIELD_SCHEDULE => self::RESTRICTION_EARLIER_ONLY, + ]; + } + + return []; + } + + /** + * Whether the field may be edited at all at this stage. + */ + public function allows(string $field): bool + { + return !array_key_exists($field, $this->locked()); + } + + /** + * Why a change to the field is refused, or null when it is allowed. + * + * Context keys: + * - items: `adds` (bool) whether the change adds or changes items rather than only removing them. + * - schedule: `from` and `to` (the current and requested scheduled time). + */ + public function check(string $field, array $context = []): ?string + { + $locked = $this->locked(); + if (isset($locked[$field])) { + return $locked[$field]; + } + + $restriction = $this->restrictions()[$field] ?? null; + + if ($restriction === self::RESTRICTION_REMOVAL_ONLY && ($context['adds'] ?? true)) { + return 'Once the order is ready, items can only be removed.'; + } + + if ($restriction === self::RESTRICTION_EARLIER_ONLY && !static::isEarlier($context['from'] ?? null, $context['to'] ?? null)) { + return 'Once the order is ready, the schedule can only be moved earlier.'; + } + + return null; + } + + /** + * Refuse the edit when any requested field is locked. + * + * @param array $changes field => context (see check()), or field => true + * + * @throws OrderEditLockedException + */ + public function assert(array $changes): void + { + $refused = []; + + foreach ($changes as $field => $context) { + $reason = $this->check($field, is_array($context) ? $context : []); + if ($reason !== null) { + $refused[$field] = $reason; + } + } + + if ($refused) { + throw new OrderEditLockedException($refused); + } + } + + /** + * Whether item changes at this status must be flagged for the kitchen. + */ + public function flagsKitchenChanges(): bool + { + return $this->status() === 'preparing'; + } + + /** + * The policy as data, for clients that mirror it. + */ + public function toArray(): array + { + return [ + 'stage' => $this->stage(), + 'status' => $this->status(), + 'locked' => (object) $this->locked(), + 'restrictions' => (object) $this->restrictions(), + ]; + } + + protected function orderConfig(): ?OrderConfig + { + try { + $config = $this->order->relationLoaded('orderConfig') ? $this->order->orderConfig : $this->order->orderConfig()->first(); + } catch (\Throwable) { + return null; + } + + return $config instanceof OrderConfig ? $config : null; + } + + protected static function isEarlier(mixed $from, mixed $to): bool + { + if (empty($from) || empty($to)) { + return false; + } + + try { + return Carbon::parse($to)->lessThanOrEqualTo(Carbon::parse($from)); + } catch (\Throwable) { + return false; + } + } +} diff --git a/server/src/Support/TrackingPublisher.php b/server/src/Support/TrackingPublisher.php index 5a9ab4679..2f8b140ae 100644 --- a/server/src/Support/TrackingPublisher.php +++ b/server/src/Support/TrackingPublisher.php @@ -79,6 +79,18 @@ public function orderChanged(Order $order): bool return $this->queue($order->uuid, $reason, $order->getConnectionName()); } + /** + * An order's stops were edited: queue an update so customers see the new route and ETAs. + */ + public function routeChanged(Order $order): bool + { + if (!static::enabled() || !$order->uuid) { + return false; + } + + return $this->queue($order->uuid, TrackingUpdate::REASON_ROUTE, $order->getConnectionName()); + } + /** * A waypoint or entity activity changed: queue an update for its order. */ diff --git a/server/src/Support/TrackingUpdate.php b/server/src/Support/TrackingUpdate.php index fdcdffc6a..fe7986738 100644 --- a/server/src/Support/TrackingUpdate.php +++ b/server/src/Support/TrackingUpdate.php @@ -36,6 +36,8 @@ final class TrackingUpdate public const REASON_LOCATION = 'location'; + public const REASON_ROUTE = 'route'; + /** * Decimal places kept on published coordinates: 4 places is roughly 11 m. */ diff --git a/server/tests/ApiEntityControllerContractsTest.php b/server/tests/ApiEntityControllerContractsTest.php index 4f6efc5b8..7581a8e44 100644 --- a/server/tests/ApiEntityControllerContractsTest.php +++ b/server/tests/ApiEntityControllerContractsTest.php @@ -4,8 +4,11 @@ use Fleetbase\FleetOps\Http\Requests\CreateEntityRequest; use Fleetbase\FleetOps\Http\Requests\UpdateEntityRequest; use Fleetbase\FleetOps\Models\Entity; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; +use Fleetbase\FleetOps\Support\OrderEdit; +use Fleetbase\FleetOps\Support\OrderEditPolicy; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -327,3 +330,84 @@ function fleetopsApiEntityController(): FleetOpsApiEntityControllerProbe 'status' => 404, ]); })->with(['update', 'find', 'delete']); + +/** + * Entities that belong to an order: the order's edit uses the real lock policy and records its commit. + */ +class FleetOpsApiEntityOrderControllerProbe extends FleetOpsApiEntityControllerProbe +{ + public ?Order $order = null; + + public array $edits = []; + + protected function orderEditForPayload(?string $payloadUuid): ?OrderEdit + { + if (!$this->order) { + return null; + } + + return $this->edits[] = new FleetOpsApiEntityOrderEditFake($this->order); + } +} + +class FleetOpsApiEntityOrderEditFake extends OrderEdit +{ + public int $commits = 0; + + public function __construct(public Order $order) + { + $this->policy = OrderEditPolicy::for($order); + } + + public function commit(array $extra = [], bool $notifyDriver = true): array + { + $this->commits++; + + return ['items']; + } +} + +function fleetopsApiEntityOrderController(string $status): FleetOpsApiEntityOrderControllerProbe +{ + $controller = new FleetOpsApiEntityOrderControllerProbe(); + $controller->order = new Order(); + $controller->order->setRawAttributes(['uuid' => 'order-uuid', 'public_id' => 'order_entity', 'status' => $status], true); + $controller->entity = new FleetOpsApiEntityFake(); + $controller->entity->setRawAttributes(['uuid' => 'entity-uuid', 'payload_uuid' => 'payload-uuid'], true); + + return $controller; +} + +test('api entity controller announces item edits on orders that allow them', function () { + $controller = fleetopsApiEntityOrderController('created'); + + $controller->create(new FleetOpsCreateEntityRequestFake(['name' => 'Latte', 'payload' => 'payload_public'])); + $controller->update('entity_public', new FleetOpsUpdateEntityRequestFake(['name' => 'Iced Latte'])); + $controller->delete('entity_public', new Request()); + + expect(array_map(fn ($edit) => $edit->commits, $controller->edits))->toBe([1, 1, 1]) + ->and($controller->createdEntities)->toHaveCount(1) + ->and($controller->entity->deletedForTest)->toBeTrue(); +}); + +test('api entity controller refuses item edits the order status locks', function () { + $dispatched = fleetopsApiEntityOrderController('dispatched'); + $created = $dispatched->create(new FleetOpsCreateEntityRequestFake(['name' => 'Latte', 'payload' => 'payload_public'])); + $deleted = $dispatched->delete('entity_public', new Request()); + + expect($created->getStatusCode())->toBe(422) + ->and($created->getData(true)['locked'])->toBe(['items' => 'Items cannot be changed once a driver has the order.']) + ->and($deleted->getStatusCode())->toBe(422) + ->and($dispatched->createdEntities)->toBe([]) + ->and($dispatched->entity->deletedForTest)->toBeFalse(); + + // once ready, an item may be removed but not changed + $ready = fleetopsApiEntityOrderController('ready'); + $updated = $ready->update('entity_public', new FleetOpsUpdateEntityRequestFake(['name' => 'Iced Latte'])); + $ready->delete('entity_public', new Request()); + + expect($updated->getStatusCode())->toBe(422) + ->and($updated->getData(true)['error'])->toBe('Once the order is ready, items can only be removed.') + ->and($ready->entity->updatedPayloads)->toBe([]) + ->and($ready->entity->deletedForTest)->toBeTrue(); +}); diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index d22c0394d..58c473fde 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -1,5 +1,6 @@ order; } + protected function beginOrderEdit(Order $order): OrderEdit + { + $this->edit ??= new FleetOpsApiOrderEditFake($order); + $this->edit->lockWith = $this->editLockedWith; + + return $this->edit; + } + + protected function lockedEditResponse(OrderEditLockedException $exception) + { + return ['apiError' => $exception->getMessage(), 'status' => 422, 'locked' => $exception->locked]; + } + protected function drivingDistanceAndTime(mixed $origin, mixed $destination): mixed { return $this->matrix ?? (object) ['distance' => 1200, 'time' => 360]; @@ -244,6 +262,43 @@ protected function apiError(string $message, int $status = 400) } } +/** + * Records the edit guard and commit instead of reading and announcing through the database. + */ +class FleetOpsApiOrderEditFake extends OrderEdit +{ + public array $guarded = []; + public array $lockWith = []; + public int $commits = 0; + + public function __construct(public Order $order) + { + $this->policy = OrderEditPolicy::for($order); + } + + public function requestedChanges(array $input, string $entitiesMode = self::ENTITIES_APPEND): array + { + return ['input' => $input, 'entities_mode' => $entitiesMode]; + } + + public function guard(array $changes): static + { + $this->guarded[] = $changes; + if ($this->lockWith) { + throw new OrderEditLockedException($this->lockWith); + } + + return $this; + } + + public function commit(array $extra = [], bool $notifyDriver = true): array + { + $this->commits++; + + return []; + } +} + class FleetOpsApiOrderCrudFake extends Order { public array $lookups = []; @@ -383,6 +438,7 @@ public function cancel() return $this; } + public function tracker(): OrderTracker { return $this->trackerFake; @@ -447,6 +503,13 @@ public function setEntities($entities = []) return $this; } + public function syncEntities($entities = []): array + { + $this->calls[] = ['sync-entities', $entities]; + + return ['created' => [], 'updated' => [], 'deleted' => []]; + } + public function setCurrentWaypoint(Place|Waypoint $destination, bool $save = true): Payload { $this->calls[] = ['current', $destination instanceof Place, $save]; @@ -1079,3 +1142,80 @@ public function isString(string $key): bool ])->and($controller->createdProofs)->toBe([]); } }); + +test('api order controller refuses order edits the status locks with a 422 and writes nothing', function () { + $order = new FleetOpsApiOrderCrudFake(['status' => 'completed']); + $payload = new FleetOpsApiOrderPayloadFake(); + $order->payload = $payload; + + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = $order; + $controller->editLockedWith = ['dropoff' => 'The order is completed and can no longer be changed.']; + + $response = $controller->update('order-public', new FleetOpsApiOrderUpdateRequestFake([ + 'payload' => ['dropoff' => ['name' => 'Somewhere else']], + ])); + + expect($response)->toBe([ + 'apiError' => 'The order is completed and can no longer be changed.', + 'status' => 422, + 'locked' => ['dropoff' => 'The order is completed and can no longer be changed.'], + ]) + ->and($payload->calls)->toBe([]) + ->and($payload->savedForTest)->toBeFalse() + ->and($order->updates)->toBe([]) + ->and($controller->edit->commits)->toBe(0); +}); + +test('api order controller passes the edit to the guard, keeps cod 0, replaces entities and commits once', function () { + $order = new FleetOpsApiOrderCrudFake(['status' => 'dispatched']); + $payload = new FleetOpsApiOrderPayloadFake(); + $order->payload = $payload; + + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = $order; + + $controller->update('order-public', new FleetOpsApiOrderUpdateRequestFake([ + 'payload' => [ + 'dropoff' => ['name' => 'New drop'], + 'entities' => [['id' => 'entity_keep', 'name' => 'Latte']], + 'entities_mode' => 'replace', + 'cod_amount' => 0, + ], + 'notes' => 'Leave at the door', + ])); + + expect($controller->edit->guarded)->toBe([[ + 'input' => [ + 'dropoff' => ['name' => 'New drop'], + 'entities' => [['id' => 'entity_keep', 'name' => 'Latte']], + 'notes' => 'Leave at the door', + 'cod_amount' => 0, + ], + 'entities_mode' => 'replace', + ]]) + ->and($payload->calls)->toContain(['sync-entities', [['id' => 'entity_keep', 'name' => 'Latte']]]) + ->and(collect($payload->calls)->where(0, 'entities')->all())->toBe([]) + ->and($payload->getAttributes()['cod_amount'])->toBe(0) + ->and(collect($payload->calls)->where(0, 'current')->all())->toBe([]) + ->and($controller->edit->commits)->toBe(1); +}); + +test('api order controller reads a top-level cod amount and appends entities by default', function () { + $order = new FleetOpsApiOrderCrudFake(); + $payload = new FleetOpsApiOrderPayloadFake(); + $order->payload = $payload; + + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = $order; + + $controller->update('order-public', new FleetOpsApiOrderUpdateRequestFake([ + 'entities' => [['name' => 'Crate']], + 'cod_amount' => '2500', + ])); + + expect($payload->calls)->toContain(['entities', [['name' => 'Crate']]]) + ->and($payload->getAttributes()['cod_amount'])->toBe(2500) + ->and($controller->edit->guarded[0]['input'])->toMatchArray(['cod_amount' => '2500']) + ->and($controller->edit->guarded[0]['entities_mode'])->toBe('append'); +}); diff --git a/server/tests/ApiPayloadControllerContractsTest.php b/server/tests/ApiPayloadControllerContractsTest.php index 6aa5b2584..2346dc629 100644 --- a/server/tests/ApiPayloadControllerContractsTest.php +++ b/server/tests/ApiPayloadControllerContractsTest.php @@ -3,9 +3,12 @@ use Fleetbase\FleetOps\Http\Controllers\Api\v1\PayloadController; use Fleetbase\FleetOps\Http\Requests\CreatePayloadRequest; use Fleetbase\FleetOps\Http\Requests\UpdatePayloadRequest; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\Waypoint; +use Fleetbase\FleetOps\Support\OrderEdit; +use Fleetbase\FleetOps\Support\OrderEditPolicy; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -124,6 +127,15 @@ public function setEntities($entities = []) return $this; } + public array $entitySyncs = []; + + public function syncEntities($entities = []): array + { + $this->entitySyncs[] = $entities; + + return ['created' => [], 'updated' => [], 'deleted' => []]; + } + public function getPickupOrFirstWaypoint(): ?Place { return $this->firstWaypoint; @@ -312,3 +324,112 @@ function fleetopsApiPayloadController(): FleetOpsApiPayloadControllerProbe 'status' => 404, ]); })->with(['update', 'find', 'delete']); + +/** + * A payload that belongs to an order: the order's edit is recorded instead of read from the database. + */ +class FleetOpsApiPayloadOrderControllerProbe extends FleetOpsApiPayloadControllerProbe +{ + public ?Order $order = null; + + public ?FleetOpsApiPayloadOrderEditFake $edit = null; + + protected function orderForPayload(Payload $payload): ?Order + { + return $this->order; + } + + protected function beginOrderEdit(Order $order): OrderEdit + { + return $this->edit ??= new FleetOpsApiPayloadOrderEditFake($order); + } +} + +class FleetOpsApiPayloadOrderEditFake extends OrderEdit +{ + public array $requested = []; + public int $commits = 0; + + public function __construct(public Order $order) + { + $this->policy = OrderEditPolicy::for($order); + } + + public function requestedChanges(array $input, string $entitiesMode = self::ENTITIES_APPEND): array + { + $this->requested[] = [$input, $entitiesMode]; + + return parent::requestedChanges(array_diff_key($input, ['entities' => true, 'cod_amount' => true]), $entitiesMode) + + (isset($input['entities']) ? ['items' => ['adds' => $entitiesMode !== self::ENTITIES_REPLACE]] : []); + } + + public function commit(array $extra = [], bool $notifyDriver = true): array + { + $this->commits++; + + return []; + } +} + +function fleetopsApiPayloadOrderController(string $status): FleetOpsApiPayloadOrderControllerProbe +{ + $controller = new FleetOpsApiPayloadOrderControllerProbe(); + $controller->payload = new FleetOpsApiPayloadEndpointFake(); + $controller->payload->firstWaypoint = fleetopsApiPayloadPlace(); + $controller->order = new Order(); + $controller->order->setRawAttributes(['uuid' => 'order-uuid', 'public_id' => 'order_payload', 'status' => $status], true); + + return $controller; +} + +test('api payload controller keeps a cash to collect of zero and still skips empty values', function () { + $controller = fleetopsApiPayloadController(); + + $response = $controller->update('payload-public', fleetopsUpdatePayloadRequest([ + 'cod_amount' => 0, + 'type' => '', + 'provider' => null, + ])); + + // the first fill is the model constructor's + expect(end($response['payload']->filledPayloads))->toBe(['cod_amount' => 0]) + ->and($response['payload']->getAttributes()['cod_amount'])->toBe(0); +}); + +test('api payload controller edits an order payload through the order edit', function () { + $controller = fleetopsApiPayloadOrderController('dispatched'); + + $response = $controller->update('payload-public', fleetopsUpdatePayloadRequest([ + 'dropoff' => ['public_id' => 'dropoff-public'], + 'cod_amount' => 1500, + ])); + + expect($response['payload']->dropoffAssignments)->toBe([['public_id' => 'dropoff-public']]) + ->and($response['payload']->currentWaypointAssignments)->toBe([]) + ->and($controller->edit->requested[0])->toBe([['dropoff' => ['public_id' => 'dropoff-public'], 'cod_amount' => 1500], 'append']) + ->and($controller->edit->commits)->toBe(1); +}); + +test('api payload controller replaces entities on request and refuses locked item edits', function () { + $controller = fleetopsApiPayloadOrderController('created'); + + $response = $controller->update('payload-public', fleetopsUpdatePayloadRequest([ + 'entities' => [['id' => 'entity_keep']], + 'entities_mode' => 'replace', + ])); + + expect($response['payload']->entitySyncs)->toBe([[['id' => 'entity_keep']]]) + ->and($response['payload']->entityAssignments)->toBe([]); + + $locked = fleetopsApiPayloadOrderController('dispatched'); + $response = $locked->update('payload-public', fleetopsUpdatePayloadRequest([ + 'entities' => [['name' => 'Extra']], + ])); + + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true)['error'])->toBe('Items cannot be changed once a driver has the order.') + ->and($response->getData(true)['locked'])->toBe(['items' => 'Items cannot be changed once a driver has the order.']) + ->and($locked->payload->entityAssignments)->toBe([]) + ->and($locked->payload->savedForTest)->toBeFalse() + ->and($locked->edit->commits)->toBe(0); +}); diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php index a92601055..cd970116c 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php @@ -1,5 +1,6 @@ policy = OrderEditPolicy::for($order); + } + + public function requestedChanges(array $input, string $entitiesMode = self::ENTITIES_APPEND): array + { + return $input; + } + + public function guard(array $changes): static + { + $this->guarded[] = $changes; + if ($this->lockWith) { + throw new OrderEditLockedException($this->lockWith); + } + + return $this; + } + + public function commit(array $extra = [], bool $notifyDriver = true): array + { + $this->commits++; + + return []; + } +} + class FleetOpsInternalOrderLifecycleControllerProbe extends OrderController { public Collection $orders; @@ -257,6 +297,18 @@ protected function findOrderRouteForEdit(string $uuid): ?Order return $this->order; } + public ?FleetOpsInternalOrderEditFake $edit = null; + + public array $editLockedWith = []; + + protected function beginOrderEdit(Order $order): OrderEdit + { + $this->edit ??= new FleetOpsInternalOrderEditFake($order); + $this->edit->lockWith = $this->editLockedWith; + + return $this->edit; + } + protected function orderResponse(Order $order): array { return ['order' => $order]; @@ -416,6 +468,7 @@ public function cancel() return $this; } + public function dispatch(bool $save = true): Order { $this->dispatchedForTest = true; @@ -911,10 +964,29 @@ function fleetopsSuppressStrNullDeprecations(): Closure ->and($payload->calls)->toContain(['setDropoff', ['name' => 'Dropoff'], ['save' => true]]) ->and($payload->calls)->toContain(['setReturn', ['name' => 'Return'], ['save' => true]]) ->and($payload->calls)->toContain(['updateWaypoints', [['name' => 'Waypoint']]]) - ->and($payload->calls)->toContain(['setCurrentWaypoint', $startingDestination, true]) + // the current waypoint is reconciled by the committed edit, not reset to the first stop here + ->and(collect($payload->calls)->where(0, 'setCurrentWaypoint')->all())->toBe([]) + ->and($controller->edit->guarded)->toBe([['pickup' => true, 'dropoff' => true, 'return' => true, 'waypoints' => true]]) + ->and($controller->edit->commits)->toBe(1) ->and($order->loadedRelations)->toBe([['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints']]); }); +test('internal order controller refuses route edits on finished orders with a 422', function () { + $payload = fleetopsInternalOrderLifecyclePayload(fleetopsInternalOrderLifecyclePlace('starting-place')); + $order = fleetopsInternalOrderLifecycleOrder('order-route', 'completed'); + $order->setRelation('payload', $payload); + $controller = fleetopsInternalOrderLifecycleController([$order]); + $controller->editLockedWith = ['dropoff' => 'The order is completed and can no longer be changed.']; + + $response = $controller->editOrderRoute('order-route', new Request(['dropoff' => ['name' => 'Dropoff']])); + + // the harness response factory keeps only the message; the app's error macro adds `locked` + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true)['error'])->toBe('The order is completed and can no longer be changed.') + ->and($payload->calls)->toBe([]) + ->and($controller->edit->commits)->toBe(0); +}); + test('internal order controller clears route endpoints and falls back to dropoff destination', function () { $fallbackDestination = fleetopsInternalOrderLifecyclePlace('fallback-place'); $payload = fleetopsInternalOrderLifecyclePayload(null, $fallbackDestination); @@ -933,7 +1005,7 @@ function fleetopsSuppressStrNullDeprecations(): Closure ->and($payload->calls)->toContain(['removePlace', 'dropoff', ['save' => true]]) ->and($payload->calls)->toContain(['removePlace', 'return', ['save' => true]]) ->and($payload->calls)->toContain(['removeWaypoints']) - ->and($payload->calls)->toContain(['setCurrentWaypoint', $fallbackDestination, true]); + ->and($controller->edit->commits)->toBe(1); }); test('internal order controller reports missing order route edits', function () { diff --git a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php new file mode 100644 index 000000000..64c68ebee --- /dev/null +++ b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php @@ -0,0 +1,47 @@ +setRawAttributes(['uuid' => 'tn-1', 'tracking_number' => 'FLB-CHANGE-1'], true); + + $order = new Order(); + $order->setRawAttributes([ + 'uuid' => 'order-uuid', + 'public_id' => 'order_change', + 'company_uuid' => 'company-1', + 'driver_assigned_uuid' => 'driver-uuid', + 'meta' => json_encode(['offer' => ['driver' => 'driver_one', 'status' => 'expired', 'expires_at' => '2026-10-13T12:05:00Z']]), + ], true); + $order->setRelation('trackingNumber', $trackingNumber); + $order->setRelation('driverAssigned', null); + + return $order; +} + +test('order changed pushes the changes to the driver with fcm safe data', function () { + $notification = new OrderChanged(fleetopsOrderChangeNotificationOrder(), ['dropoff', 'items'], 'Drop-off changed to 8 Marina View · Items changed'); + + expect($notification->title)->toBe('Order FLB-CHANGE-1 changed') + ->and($notification->message)->toBe('Drop-off changed to 8 Marina View · Items changed') + ->and($notification->data)->toBe(['type' => 'order_changed', 'id' => 'order_change', 'changes' => ['dropoff', 'items']]) + ->and($notification->fcmData())->toBe(['type' => 'order_changed', 'id' => 'order_change', 'changes' => 'dropoff,items']) + ->and($notification->via(null))->toBe(['broadcast', FcmChannel::class, ApnChannel::class]) + ->and($notification->toArray())->toMatchArray(['event' => 'order.changed_notification', 'data' => $notification->data]) + ->and(array_map(fn ($channel) => $channel->name, $notification->broadcastOn()))->toContain('driver.driver-uuid') + ->and((new OrderChanged(fleetopsOrderChangeNotificationOrder()))->message)->toBe('This order was changed, tap for details.'); +}); diff --git a/server/tests/Unit/Support/OrderEditPolicyTest.php b/server/tests/Unit/Support/OrderEditPolicyTest.php new file mode 100644 index 000000000..8153865e0 --- /dev/null +++ b/server/tests/Unit/Support/OrderEditPolicyTest.php @@ -0,0 +1,128 @@ +setRawAttributes(array_merge(['uuid' => 'order-uuid', 'public_id' => 'order_policy', 'status' => $status], $attributes), true); + $order->setRelation('orderConfig', $config); + + return $order; +} + +test('open statuses leave every field editable', function () { + foreach (['created', 'pending', 'accepted', 'confirmed', 'preparing'] as $status) { + $policy = OrderEditPolicy::for(fleetopsEditPolicyOrder($status)); + + expect($policy->stage())->toBe(OrderEditPolicy::STAGE_OPEN) + ->and($policy->locked())->toBe([]) + ->and($policy->restrictions())->toBe([]) + ->and($policy->check('items', ['adds' => true]))->toBeNull() + ->and($policy->check('schedule', ['from' => '2026-10-13 12:00:00', 'to' => '2026-10-14 12:00:00']))->toBeNull(); + } + + expect(OrderEditPolicy::for(fleetopsEditPolicyOrder('preparing'))->flagsKitchenChanges())->toBeTrue() + ->and(OrderEditPolicy::for(fleetopsEditPolicyOrder('created'))->flagsKitchenChanges())->toBeFalse(); +}); + +test('ready orders only allow removing items and moving the schedule earlier', function () { + $policy = OrderEditPolicy::for(fleetopsEditPolicyOrder('pickup_ready')); + + expect($policy->stage())->toBe(OrderEditPolicy::STAGE_READY) + ->and($policy->locked())->toBe([]) + ->and($policy->restrictions())->toBe(['items' => 'removal_only', 'schedule' => 'earlier_only']) + ->and($policy->check('dropoff'))->toBeNull() + ->and($policy->check('contact'))->toBeNull() + ->and($policy->check('items', ['adds' => false]))->toBeNull() + ->and($policy->check('items', ['adds' => true]))->toBe('Once the order is ready, items can only be removed.') + ->and($policy->check('schedule', ['from' => '2026-10-13 12:00:00', 'to' => '2026-10-13 11:30:00']))->toBeNull() + ->and($policy->check('schedule', ['from' => '2026-10-13 12:00:00', 'to' => '2026-10-13 12:30:00']))->toBe('Once the order is ready, the schedule can only be moved earlier.') + ->and($policy->check('schedule', ['from' => null, 'to' => '2026-10-13 12:30:00']))->not->toBeNull(); +}); + +test('orders a driver has keep route contact and notes editable but lock items and schedule', function () { + $cases = [ + fleetopsEditPolicyOrder('dispatched'), + fleetopsEditPolicyOrder('driver_enroute'), + fleetopsEditPolicyOrder('enroute'), + fleetopsEditPolicyOrder('created', ['started' => true]), + fleetopsEditPolicyOrder('at_customs', ['dispatched' => true]), + ]; + + foreach ($cases as $order) { + $policy = OrderEditPolicy::for($order); + + expect($policy->stage())->toBe(OrderEditPolicy::STAGE_IN_TRANSIT) + ->and(array_keys($policy->locked()))->toBe(['items', 'schedule']) + ->and($policy->allows('dropoff'))->toBeTrue() + ->and($policy->allows('waypoints'))->toBeTrue() + ->and($policy->allows('contact'))->toBeTrue() + ->and($policy->allows('notes'))->toBeTrue() + ->and($policy->allows('cod_amount'))->toBeTrue() + ->and($policy->allows('items'))->toBeFalse(); + } + + // a custom status before dispatch is still open + expect(OrderEditPolicy::for(fleetopsEditPolicyOrder('awaiting_stock'))->stage())->toBe(OrderEditPolicy::STAGE_OPEN); +}); + +test('completed canceled and expired orders and the config terminal codes lock everything', function () { + foreach (['completed', 'canceled', 'expired'] as $status) { + $policy = OrderEditPolicy::for(fleetopsEditPolicyOrder($status)); + + expect($policy->stage())->toBe(OrderEditPolicy::STAGE_TERMINAL) + ->and($policy->isTerminal())->toBeTrue() + ->and(array_keys($policy->locked()))->toBe(OrderEditPolicy::FIELDS) + ->and($policy->check('notes'))->toBe(sprintf('The order is %s and can no longer be changed.', $status)); + } + + $config = new OrderConfig(); + $config->setRawAttributes([ + 'uuid' => 'config-uuid', + 'meta' => json_encode(['lifecycle' => ['initial' => 'booked', 'completed' => 'delivered', 'terminal' => ['no_show']]]), + 'flow' => json_encode([ + 'booked' => ['key' => 'booked', 'code' => 'booked', 'status' => 'Booked', 'activities' => ['delivered']], + 'delivered' => ['key' => 'delivered', 'code' => 'delivered', 'status' => 'Delivered', 'complete' => true], + 'no_show' => ['key' => 'no_show', 'code' => 'no_show', 'status' => 'No show'], + ]), + ], true); + + expect(OrderEditPolicy::for(fleetopsEditPolicyOrder('delivered', [], $config))->stage())->toBe(OrderEditPolicy::STAGE_TERMINAL) + ->and(OrderEditPolicy::for(fleetopsEditPolicyOrder('no_show', [], $config))->stage())->toBe(OrderEditPolicy::STAGE_TERMINAL) + ->and(OrderEditPolicy::for(fleetopsEditPolicyOrder('booked', [], $config))->stage())->toBe(OrderEditPolicy::STAGE_OPEN); +}); + +test('asserting a locked edit throws with every refused field and the policy serializes', function () { + $policy = OrderEditPolicy::for(fleetopsEditPolicyOrder('dispatched')); + + $policy->assert(['dropoff' => true, 'notes' => true]); + + try { + $policy->assert(['dropoff' => true, 'items' => ['adds' => false], 'schedule' => ['from' => 'a', 'to' => 'b']]); + $this->fail('Expected the edit to be refused'); + } catch (OrderEditLockedException $exception) { + expect($exception->locked)->toBe([ + 'items' => 'Items cannot be changed once a driver has the order.', + 'schedule' => 'The schedule cannot be changed once a driver has the order.', + ]) + ->and($exception->getCode())->toBe(422) + ->and($exception->getMessage())->toContain('Items cannot be changed') + ->and($exception->toResponseData())->toBe(['locked' => $exception->locked]); + } + + $serialized = OrderEditPolicy::for(fleetopsEditPolicyOrder('ready'))->toArray(); + + expect($serialized['stage'])->toBe('ready') + ->and($serialized['status'])->toBe('ready') + ->and((array) $serialized['locked'])->toBe([]) + ->and((array) $serialized['restrictions'])->toBe(['items' => 'removal_only', 'schedule' => 'earlier_only']) + ->and(fleetopsEditPolicyOrder('ready')->editPolicy())->toBeInstanceOf(OrderEditPolicy::class); +}); diff --git a/server/tests/Unit/Support/OrderOperationsTest.php b/server/tests/Unit/Support/OrderOperationsTest.php new file mode 100644 index 000000000..89ee3a7c2 --- /dev/null +++ b/server/tests/Unit/Support/OrderOperationsTest.php @@ -0,0 +1,379 @@ +dispatch(...$arguments); + } +} + +use Fleetbase\FleetOps\Console\Commands\ExpireOrderOffers; +use Fleetbase\FleetOps\Events\OrderChanged; +use Fleetbase\FleetOps\Events\OrderOfferChanged; +use Fleetbase\FleetOps\Models\Driver; +use Fleetbase\FleetOps\Models\Entity; +use Fleetbase\FleetOps\Models\Order; +use Fleetbase\FleetOps\Models\Payload; +use Fleetbase\FleetOps\Support\OrderEdit; +use Illuminate\Database\ConnectionResolver; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\SQLiteConnection; +use Illuminate\Support\Carbon; + +/** + * Covers order edits against SQLite: replacing a payload's entities, keeping the + * driver's stop when an in-progress route changes, the order.changed event an edit + * commits, and the driver offer lifecycle with its expiry command. + */ +class FleetOpsOrderOpsEventRecorder +{ + public array $dispatched = []; + + public function dispatch($event, $payload = [], $halt = false) + { + if (is_object($event)) { + $this->dispatched[] = $event; + } + + return $halt ? null : []; + } + + public function of(string $class): array + { + return array_values(array_filter($this->dispatched, fn ($event) => $event instanceof $class)); + } + + public function __call($method, $arguments) + { + return null; + } +} + +/** + * Runs only the uuid and public id generation the models register, none of the + * observers and tracking number hooks, which reach for services the harness lacks. + */ +class FleetOpsOrderOpsModelDispatcher extends Illuminate\Events\Dispatcher +{ + public function listen($events, $listener = null) + { + if (!$listener instanceof Closure) { + return; + } + + $file = (string) (new ReflectionFunction($listener))->getFileName(); + if (str_contains($file, 'HasUuid') || str_contains($file, 'HasPublicId')) { + parent::listen($events, $listener); + } + } +} + +class FleetOpsOrderOpsDriver extends Driver +{ + public array $notified = []; + + public function notify($instance) + { + $this->notified[] = $instance; + } +} + +function fleetopsOrderOpsBoot(): SQLiteConnection +{ + $pdo = new PDO('sqlite::memory:'); + $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + // models register their uuid hooks once, when first booted, so every test shares one dispatcher + static $modelDispatcher; + EloquentModel::setEventDispatcher($modelDispatcher ??= new FleetOpsOrderOpsModelDispatcher()); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + class_exists(Fleetbase\FleetOps\Models\Contact::class); + + foreach (['DNS2D', 'DNS1D'] as $barcode) { + app()->instance($barcode, new class { + public function __call($method, $arguments) + { + return 'barcode'; + } + }); + } + + app()->instance('events', new FleetOpsOrderOpsEventRecorder()); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'orders' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'tracking_number_uuid', 'driver_assigned_uuid', 'customer_uuid', 'customer_type', 'order_config_uuid', 'status', 'type', 'notes', 'scheduled_at', 'started', 'dispatched', 'adhoc', 'distance', 'time', 'meta', 'orchestrator_priority', '_key'], + 'payloads' => ['uuid', 'public_id', 'company_uuid', 'pickup_uuid', 'dropoff_uuid', 'return_uuid', 'current_waypoint_uuid', 'cod_amount', 'cod_currency', 'type', 'meta', '_key'], + 'places' => ['uuid', 'public_id', 'company_uuid', 'owner_uuid', 'name', 'street1', 'city', 'country', 'location', 'meta', 'type', '_key', '_import_id'], + 'waypoints' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'place_uuid', 'tracking_number_uuid', 'customer_uuid', 'customer_type', 'order', 'type', '_import_id', '_key'], + 'entities' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'payload_uuid', 'destination_uuid', 'tracking_number_uuid', 'customer_uuid', 'customer_type', 'photo_uuid', 'name', 'type', 'price', 'meta', 'qr_code', 'barcode', '_import_id', '_key'], + 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'current_job_uuid', 'status', '_key'], + 'contacts' => ['uuid', 'public_id', 'company_uuid', 'name', 'type', 'meta', '_key'], + 'tracking_numbers' => ['uuid', 'public_id', 'company_uuid', 'tracking_number', 'owner_uuid', 'owner_type', 'region', 'qr_code', 'barcode', 'status_uuid', '_key'], + 'companies' => ['uuid', 'public_id', 'name', 'country'], + 'tracking_statuses' => ['uuid', 'public_id', 'company_uuid', 'tracking_number_uuid', 'code', 'status', 'details', 'location', 'city', 'province', 'postal_code', 'country', 'complete', '_key'], + ]; + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + session(['company' => 'company-1', 'api_key' => 'console', 'user' => null]); + $connection->table('companies')->insert(['uuid' => 'company-1', 'name' => 'Acme', 'country' => 'SG']); + $connection->table('places')->insert([ + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'place_pickup', 'company_uuid' => 'company-1', 'name' => 'Store', 'street1' => '1 Store Rd'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'place_drop_old', 'company_uuid' => 'company-1', 'name' => 'Old Drop', 'street1' => '2 Old Rd'], + ['uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'place_drop_new', 'company_uuid' => 'company-1', 'name' => 'Tower B', 'street1' => '8 Marina View'], + ['uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'public_id' => 'place_stop_d', 'company_uuid' => 'company-1', 'name' => 'Stop D', 'street1' => '4 D St'], + ]); + $connection->table('drivers')->insert(['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-1', 'current_job_uuid' => 'order-1']); + + return $connection; +} + +function fleetopsOrderOpsEvents(): FleetOpsOrderOpsEventRecorder +{ + return app('events'); +} + +function fleetopsOrderOpsOrder(SQLiteConnection $connection, array $order = [], array $payload = []): Order +{ + $connection->table('payloads')->insert(array_merge([ + 'uuid' => 'payload-1', + 'public_id' => 'payload_one', + 'company_uuid' => 'company-1', + 'pickup_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'dropoff_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + 'current_waypoint_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ], $payload)); + $connection->table('orders')->insert(array_merge([ + 'uuid' => 'order-1', + 'public_id' => 'order_one', + 'company_uuid' => 'company-1', + 'payload_uuid' => 'payload-1', + 'status' => 'created', + ], $order)); + + return Order::where('uuid', 'order-1')->first(); +} + +test('sync entities updates named entities creates new ones and deletes the rest while set entities appends', function () { + $connection = fleetopsOrderOpsBoot(); + $order = fleetopsOrderOpsOrder($connection); + $payload = $order->payload; + + $payload->setEntities([['name' => 'Latte', 'price' => '450'], ['name' => 'Muffin', 'price' => '300']]); + $latte = Entity::where('name', 'Latte')->first(); + $muffin = Entity::where('name', 'Muffin')->first(); + + // resending the same list through setEntities duplicates it + $payload->setEntities([['name' => 'Latte', 'price' => '450']]); + expect(Entity::where('payload_uuid', 'payload-1')->count())->toBe(3); + Entity::where('name', 'Latte')->where('uuid', '!=', $latte->uuid)->get()->each->forceDelete(); + + expect($payload->entitiesListOnlyNamesExisting([['id' => $latte->public_id]]))->toBeTrue() + ->and($payload->entitiesListOnlyNamesExisting([['id' => $latte->public_id], ['name' => 'New']]))->toBeFalse(); + + $result = $payload->syncEntities([ + ['id' => $latte->public_id, 'name' => 'Latte', 'price' => '500'], + ['name' => 'Croissant', 'price' => '350'], + ]); + + $names = Entity::where('payload_uuid', 'payload-1')->orderBy('name')->pluck('name')->all(); + + expect($names)->toBe(['Croissant', 'Latte']) + ->and(Entity::where('uuid', $latte->uuid)->value('price'))->toBe('500') + ->and($result['updated'])->toBe([$latte->uuid]) + ->and($result['deleted'])->toBe([$muffin->uuid]) + ->and($result['created'])->toHaveCount(1) + ->and($payload->syncEntities([['uuid' => $latte->uuid, 'name' => 'Latte', 'price' => '500']])['updated'])->toBe([]); +}); + +test('route signatures report changed parts and a not started order restarts at the first stop', function () { + $connection = fleetopsOrderOpsBoot(); + $order = fleetopsOrderOpsOrder($connection, [], ['current_waypoint_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb']); + $payload = $order->payload; + $before = $payload->routeSignature(); + + $connection->table('payloads')->update(['dropoff_uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc']); + $payload = Payload::where('uuid', 'payload-1')->first(); + + expect(Payload::routeChanges($before, $payload->routeSignature()))->toBe(['dropoff']) + ->and(Payload::routeChanges($before, $before))->toBe([]); + + $payload->reconcileCurrentWaypoint($before, false); + + expect($connection->table('payloads')->value('current_waypoint_uuid'))->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); +}); + +test('an in progress order keeps the driver on their stop or moves them to its replacement', function () { + $connection = fleetopsOrderOpsBoot(); + + // heading to the pickup, the dropoff changes: the pickup stays current + $order = fleetopsOrderOpsOrder($connection, ['status' => 'driver_enroute', 'started' => '1']); + $before = $order->payload->routeSignature(); + $connection->table('payloads')->update(['dropoff_uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc']); + Payload::where('uuid', 'payload-1')->first()->reconcileCurrentWaypoint($before, true); + expect($connection->table('payloads')->value('current_waypoint_uuid'))->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + + // heading to the dropoff, the dropoff changes: the new dropoff becomes current + $connection->table('payloads')->update(['dropoff_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'current_waypoint_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb']); + $before = Payload::where('uuid', 'payload-1')->first()->routeSignature(); + $connection->table('payloads')->update(['dropoff_uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc']); + Payload::where('uuid', 'payload-1')->first()->reconcileCurrentWaypoint($before, true); + expect($connection->table('payloads')->value('current_waypoint_uuid'))->toBe('cccccccc-cccc-4ccc-8ccc-cccccccccccc'); + + // a multi-stop route loses the stop the driver was heading to: the next stop takes over + $connection->table('payloads')->update(['pickup_uuid' => null, 'dropoff_uuid' => null, 'current_waypoint_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb']); + $connection->table('waypoints')->insert([ + ['uuid' => 'wp-1', 'company_uuid' => 'company-1', 'payload_uuid' => 'payload-1', 'place_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'order' => 0], + ['uuid' => 'wp-2', 'company_uuid' => 'company-1', 'payload_uuid' => 'payload-1', 'place_uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'order' => 1], + ]); + $before = Payload::where('uuid', 'payload-1')->first()->routeSignature(); + $connection->table('waypoints')->where('uuid', 'wp-1')->update(['deleted_at' => now()]); + Payload::where('uuid', 'payload-1')->first()->reconcileCurrentWaypoint($before, true); + expect($connection->table('payloads')->value('current_waypoint_uuid'))->toBe('dddddddd-dddd-4ddd-8ddd-dddddddddddd'); +}); + +test('committing a dropoff change on a dispatched order keeps its stop and announces order changed', function () { + $connection = fleetopsOrderOpsBoot(); + $order = fleetopsOrderOpsOrder($connection, ['status' => 'dispatched', 'driver_assigned_uuid' => 'driver-1'], ['cod_amount' => '5000']); + $driver = new FleetOpsOrderOpsDriver(); + $driver->setRawAttributes(['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-1'], true); + $order->setRelation('driverAssigned', $driver); + + $edit = OrderEdit::begin($order); + + // the edit is allowed: route changes are open while a driver has the order + $edit->guard($edit->requestedChanges(['dropoff' => ['id' => 'place_drop_new']])); + + $connection->table('payloads')->update(['dropoff_uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc']); + $changes = $edit->commit(['total' => 8400, 'currency' => 'SGD', 'changed_by' => 'Jon (store)']); + + $events = fleetopsOrderOpsEvents()->of(OrderChanged::class); + + expect($changes)->toBe(['dropoff']) + ->and($connection->table('payloads')->value('current_waypoint_uuid'))->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + ->and($events)->toHaveCount(1) + ->and($events[0]->broadcastAs())->toBe('order.changed') + ->and($events[0]->channelNames)->toBe(['order.order_one', 'order.order-1', 'driver.driver_one', 'driver.driver-1', 'company.company-1']) + ->and($events[0]->data)->toMatchArray([ + 'id' => 'order_one', + 'changes' => ['dropoff'], + 'summary' => 'Drop-off changed to TOWER B - 8 MARINA VIEW', + 'cod_amount' => 5000, + 'total' => 8400, + 'currency' => 'SGD', + 'changed_by' => 'Jon (store)', + ]) + ->and($events[0]->data['dropoff']['id'])->toBe('place_drop_new') + ->and($events[0]->broadcastWith()['data'])->toBe($events[0]->data); +}); + +test('order changed pushes the assigned driver only for changes that concern them', function () { + $connection = fleetopsOrderOpsBoot(); + $order = fleetopsOrderOpsOrder($connection, ['status' => 'dispatched', 'driver_assigned_uuid' => 'driver-1']); + $driver = new FleetOpsOrderOpsDriver(); + $driver->setRawAttributes(['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-1'], true); + $order->setRelation('driverAssigned', $driver); + + $event = OrderChanged::dispatchFor($order, ['dropoff', 'items', 'dropoff'], 'Drop-off changed', ['changed_by' => 'Jon (store)', 'dropoff' => null]); + + expect($event->data['changes'])->toBe(['dropoff', 'items']) + ->and($driver->notified)->toHaveCount(1) + ->and($driver->notified[0])->toBeInstanceOf(Fleetbase\FleetOps\Notifications\OrderChanged::class) + ->and($driver->notified[0]->data)->toBe(['type' => 'order_changed', 'id' => 'order_one', 'changes' => ['dropoff', 'items']]) + ->and($driver->notified[0]->message)->toBe('Drop-off changed'); + + OrderChanged::dispatchFor($order, ['contact'], 'Customer changed'); + OrderChanged::dispatchFor($order, ['items'], 'Items changed', [], false); + + expect($driver->notified)->toHaveCount(1) + ->and(OrderChanged::dispatchFor($order, []))->toBeNull() + ->and(fleetopsOrderOpsEvents()->of(OrderChanged::class))->toHaveCount(3); +}); + +test('an edit that changes nothing announces nothing and item and notes changes are detected', function () { + $connection = fleetopsOrderOpsBoot(); + $order = fleetopsOrderOpsOrder($connection); + + expect(OrderEdit::begin($order)->commit())->toBe([]) + ->and(fleetopsOrderOpsEvents()->of(OrderChanged::class))->toBe([]); + + $edit = OrderEdit::begin($order); + $order->payload->setEntities([['name' => 'Latte']]); + $order->notes = 'Ring twice'; + $order->save(); + + expect($edit->commit())->toBe(['items', 'notes']) + ->and($connection->table('payloads')->value('current_waypoint_uuid'))->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + ->and(fleetopsOrderOpsEvents()->of(OrderChanged::class)[0]->data['summary'])->toBe('Items changed · Notes updated'); +}); + +test('requested changes name the policy fields an edit touches', function () { + $connection = fleetopsOrderOpsBoot(); + $order = fleetopsOrderOpsOrder($connection, ['status' => 'ready', 'scheduled_at' => '2026-10-13 12:00:00', 'notes' => 'Same'], ['cod_amount' => '0']); + $order->payload->setEntities([['name' => 'Latte']]); + $latte = Entity::where('name', 'Latte')->first(); + $edit = OrderEdit::begin($order); + + expect($edit->requestedChanges([ + 'dropoff' => ['id' => 'place_drop_new'], + 'entities' => [['id' => $latte->public_id]], + 'scheduled_at' => '2026-10-13 11:00:00', + 'notes' => 'Same', + 'cod_amount' => 0, + ], OrderEdit::ENTITIES_REPLACE))->toBe([ + 'dropoff' => true, + 'items' => ['adds' => false], + 'schedule' => ['from' => '2026-10-13T12:00:00Z', 'to' => '2026-10-13 11:00:00'], + ]); + + // ready: removing items and an earlier time pass, adding an item does not + $edit->guard($edit->requestedChanges(['entities' => [['id' => $latte->public_id]], 'scheduled_at' => '2026-10-13 11:00:00'], OrderEdit::ENTITIES_REPLACE)); + + expect(fn () => $edit->guard($edit->requestedChanges(['entities' => [['name' => 'Cake']]]))) + ->toThrow(Fleetbase\FleetOps\Exceptions\OrderEditLockedException::class, 'Once the order is ready, items can only be removed.') + ->and(OrderEdit::entitiesMode('replace'))->toBe('replace') + ->and(OrderEdit::entitiesMode('sync'))->toBe('replace') + ->and(OrderEdit::entitiesMode(null))->toBe('append'); +}); From e8f250afb0096b5fc7ae06af247ba20a93a73d10 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:32:09 +0800 Subject: [PATCH 2/5] fix(orders): tell a driver when an order is taken off them; send OrderDriverAssigned 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. --- server/src/Events/OrderDriverUnassigned.php | 68 +++++++++++ .../Internal/v1/DriverController.php | 9 ++ .../Internal/v1/OrderController.php | 10 ++ server/src/Models/Order.php | 65 ++++++++++- server/src/Notifications/OrderUnassigned.php | 66 +++++++++++ server/src/Observers/OrderObserver.php | 10 +- .../src/Providers/FleetOpsServiceProvider.php | 1 + .../OrderChangeNotificationsTest.php | 9 ++ .../Observers/OrderAssignmentObserverTest.php | 107 ++++++++++++++++++ .../Unit/Support/OrderOperationsTest.php | 15 +++ 10 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 server/src/Events/OrderDriverUnassigned.php create mode 100644 server/src/Notifications/OrderUnassigned.php create mode 100644 server/tests/Unit/Observers/OrderAssignmentObserverTest.php diff --git a/server/src/Events/OrderDriverUnassigned.php b/server/src/Events/OrderDriverUnassigned.php new file mode 100644 index 000000000..c5454bb39 --- /dev/null +++ b/server/src/Events/OrderDriverUnassigned.php @@ -0,0 +1,68 @@ +eventId = uniqid('event_'); + $this->sentAt = Carbon::now()->toDateTimeString(); + $this->channelNames = array_values(array_filter([ + $driver->public_id ? 'driver.' . $driver->public_id : null, + $driver->uuid ? 'driver.' . $driver->uuid : null, + $order->company_uuid ? 'company.' . $order->company_uuid : null, + ])); + $this->data = [ + 'id' => $order->public_id, + 'driver' => $driver->public_id, + ]; + } + + public function broadcastOn(): array + { + return array_map(fn (string $name) => new Channel($name), $this->channelNames); + } + + public function broadcastAs(): string + { + return 'order.driver_unassigned'; + } + + public function broadcastWith(): array + { + return [ + 'id' => $this->eventId, + 'api_version' => config('api.version'), + 'event' => $this->broadcastAs(), + 'created_at' => $this->sentAt, + 'data' => $this->data, + ]; + } +} diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index aee020d4b..6e4dcc212 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -596,6 +596,15 @@ protected function clearDriverAssignmentsForOrders($orders): void 'updated_at' => now(), ]); Order::invalidateApiCacheManually(session('company')); + + // The bulk update bypasses the order observer, so the driver taken off each + // order is told here + foreach ($orders as $order) { + $previousDriverUuid = $order->driver_assigned_uuid; + if ($previousDriverUuid) { + $order->notifyDriverUnassigned($previousDriverUuid); + } + } } protected function freshOrders($orders, array $with) diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 69a78a52f..2da065c52 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -782,11 +782,21 @@ protected function orderResponse(Order $order): array protected function assignDriverToOrders($orderUuids, Driver $driver): void { + // Drivers being replaced are told once the assignment is written + $replaced = $this->scopedToCompany(Order::whereIn('uuid', $orderUuids)) + ->whereNotNull('driver_assigned_uuid') + ->where('driver_assigned_uuid', '!=', $driver->uuid) + ->get(); + $this->scopedToCompany(Order::whereIn('uuid', $orderUuids))->update([ 'driver_assigned_uuid' => $driver->uuid, 'updated_at' => now(), ]); Order::invalidateApiCacheManually(session('company')); + + foreach ($replaced as $order) { + $order->notifyDriverUnassigned($order->driver_assigned_uuid); + } } protected function dispatchBulkAssignedDriverNotification(array $orderUuids, Driver $driver): void diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 001ff9265..0370a7240 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -8,7 +8,9 @@ use Fleetbase\FleetOps\Events\OrderCompleted; use Fleetbase\FleetOps\Events\OrderDispatched; use Fleetbase\FleetOps\Events\OrderDriverAssigned; +use Fleetbase\FleetOps\Events\OrderDriverUnassigned; use Fleetbase\FleetOps\Flow\Activity; +use Fleetbase\FleetOps\Notifications\OrderUnassigned; use Fleetbase\FleetOps\Support\LabelPdf; use Fleetbase\FleetOps\Support\OrderEditPolicy; use Fleetbase\FleetOps\Support\OrderTracker; @@ -36,6 +38,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -277,6 +280,16 @@ class Order extends Model '_key', ]; + /** + * Set for one save: the driver assignment being saved sends no assignment notification. + */ + public bool $silentDriverAssignment = false; + + /** + * Set for one save: the driver taken off the order is not sent an unassigned push (they declined it themselves). + */ + public bool $quietDriverUnassignment = false; + /** * Enforce a morph map. */ @@ -1462,6 +1475,44 @@ public function notifyDriverAssigned() } } + /** + * Tell a driver the order was taken off them: broadcasts `order.driver_unassigned` + * on their channels and pushes `{ type: "order_unassigned", id }` to their devices. + * The order is cleared as the driver's current job. + * + * @param Driver|string|null $driver the driver (or driver uuid) removed from the order + */ + public function notifyDriverUnassigned($driver): bool + { + if (is_string($driver)) { + $driver = Driver::where('uuid', $driver)->withoutGlobalScopes()->first(); + } + + if (!$driver instanceof Driver) { + return false; + } + + if ($driver->current_job_uuid && $driver->current_job_uuid === $this->uuid) { + $driver->forceFill(['current_job_uuid' => null])->saveQuietly(); + } + + try { + event(new OrderDriverUnassigned($this, $driver)); + } catch (\Throwable $exception) { + Log::warning('Unable to broadcast order.driver_unassigned', ['order' => $this->public_id, 'error' => $exception->getMessage()]); + } + + if (!$this->quietDriverUnassignment) { + try { + $driver->notify(new OrderUnassigned($this)); + } catch (\Throwable $exception) { + Log::warning('Unable to notify driver of unassignment', ['order' => $this->public_id, 'error' => $exception->getMessage()]); + } + } + + return true; + } + /** * The edit policy for the order at its current status. */ @@ -1727,7 +1778,7 @@ public function assignDriver($driver, $silent = false) return $this; } - return $this->assignDriver($driver); + return $this->assignDriver($driver, $silent); } throw new \Exception('Invalid driver provided for assignment!'); @@ -1740,12 +1791,16 @@ public function assignDriver($driver, $silent = false) $this->setRelation('driverAssigned', $driver); } - if (!$silent) { - $this->notifyDriverAssigned(); + // The order observer sends OrderDriverAssigned once the assignment is saved + // (and tells a replaced driver they were unassigned), so it is not fired here + // as well. A silent assignment is flagged for that one save. + $this->silentDriverAssignment = (bool) $silent; + try { + $this->save(); + } finally { + $this->silentDriverAssignment = false; } - $this->save(); - return $this; } diff --git a/server/src/Notifications/OrderUnassigned.php b/server/src/Notifications/OrderUnassigned.php new file mode 100644 index 000000000..b1afe886b --- /dev/null +++ b/server/src/Notifications/OrderUnassigned.php @@ -0,0 +1,66 @@ +order = $order; + $this->title = 'Order ' . (data_get($order, 'trackingNumber.tracking_number') ?? $order->public_id) . ' unassigned'; + $this->message = 'This order is no longer assigned to you.'; + $this->data = ['type' => 'order_unassigned', 'id' => $order->public_id]; + } + + public function via($notifiable): array + { + return [FcmChannel::class, ApnChannel::class]; + } + + public function toArray($notifiable = null): array + { + return [ + 'event' => 'order.unassigned_notification', + 'title' => $this->title, + 'body' => $this->message, + 'data' => $this->data, + ]; + } + + public function toFcm($notifiable) + { + return PushNotification::createFcmMessage($this->title, $this->message, $this->data); + } + + public function toApn($notifiable) + { + return PushNotification::createApnMessage($this->title, $this->message, $this->data, 'view_order'); + } +} diff --git a/server/src/Observers/OrderObserver.php b/server/src/Observers/OrderObserver.php index 796c908c7..45c9261bd 100644 --- a/server/src/Observers/OrderObserver.php +++ b/server/src/Observers/OrderObserver.php @@ -58,7 +58,15 @@ public function updated(Order $order) $order->setDriverLocationAsPickup(); if ($order->wasChanged('driver_assigned_uuid')) { - $order->notifyDriverAssigned(); + // The driver taken off the order hears about it, whether it was unassigned or reassigned. + $previousDriverUuid = $order->getOriginal('driver_assigned_uuid'); + if ($previousDriverUuid) { + $order->notifyDriverUnassigned($previousDriverUuid); + } + + if (!$order->silentDriverAssignment) { + $order->notifyDriverAssigned(); + } } $this->invalidateCache($order); diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index a60f5de5f..bb24c8932 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -253,6 +253,7 @@ public function registerNotifications() \Fleetbase\FleetOps\Notifications\RouteDeviation::class, \Fleetbase\FleetOps\Notifications\ProlongedStoppage::class, \Fleetbase\FleetOps\Notifications\OrderChanged::class, + \Fleetbase\FleetOps\Notifications\OrderUnassigned::class, ]); // Register Notifiables diff --git a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php index 64c68ebee..0682d86ec 100644 --- a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php +++ b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php @@ -45,3 +45,12 @@ function fleetopsOrderChangeNotificationOrder(): Order ->and(array_map(fn ($channel) => $channel->name, $notification->broadcastOn()))->toContain('driver.driver-uuid') ->and((new OrderChanged(fleetopsOrderChangeNotificationOrder()))->message)->toBe('This order was changed, tap for details.'); }); + +test('order unassigned tells the driver the order is no longer theirs', function () { + $notification = new OrderUnassigned(fleetopsOrderChangeNotificationOrder()); + + expect($notification->title)->toBe('Order FLB-CHANGE-1 unassigned') + ->and($notification->data)->toBe(['type' => 'order_unassigned', 'id' => 'order_change']) + ->and($notification->via(null))->toBe([FcmChannel::class, ApnChannel::class]) + ->and($notification->toArray()['event'])->toBe('order.unassigned_notification'); +}); diff --git a/server/tests/Unit/Observers/OrderAssignmentObserverTest.php b/server/tests/Unit/Observers/OrderAssignmentObserverTest.php new file mode 100644 index 000000000..1c61273d6 --- /dev/null +++ b/server/tests/Unit/Observers/OrderAssignmentObserverTest.php @@ -0,0 +1,107 @@ +originalData : ($this->originalData[$key] ?? $default); + } + + public function wasChanged($attributes = null): bool + { + return $attributes === 'driver_assigned_uuid' && $this->driverChanged; + } + + public function isDirty($attributes = null): bool + { + return $attributes === 'driver_assigned_uuid' && $this->driverDirty; + } + + public function setDriverLocationAsPickup($force = false) + { + } + + public function notifyDriverAssigned() + { + $this->events[] = 'assigned:' . $this->driver_assigned_uuid; + } + + public function notifyDriverUnassigned($driver): bool + { + $this->events[] = 'unassigned:' . $driver; + + return true; + } + + public function syncOfferWithAssignment(): Order + { + $this->events[] = 'offer'; + + return $this; + } +} + +beforeEach(function () { + Cache::swap(new Repository(new ArrayStore())); + session(['company' => 'company-uuid']); + + // public tracking is published elsewhere; it is not what these tests cover + app()->instance(TrackingPublisher::class, new class extends TrackingPublisher { + public function orderChanged(Order $order): bool + { + return false; + } + }); +}); + +test('reassigning an order tells the replaced driver and notifies the new one once', function () { + $order = new FleetOpsOrderAssignmentObserverOrder(); + $order->setRawAttributes(['uuid' => 'order-uuid', 'driver_assigned_uuid' => 'driver-new'], true); + $order->originalData = ['driver_assigned_uuid' => 'driver-old']; + + (new OrderObserver())->updated($order); + + expect($order->events)->toBe(['unassigned:driver-old', 'assigned:driver-new']); +}); + +test('unassigning tells the driver and a silent assignment sends no assignment notification', function () { + $unassigned = new FleetOpsOrderAssignmentObserverOrder(); + $unassigned->setRawAttributes(['uuid' => 'order-uuid', 'driver_assigned_uuid' => null], true); + $unassigned->originalData = ['driver_assigned_uuid' => 'driver-old']; + + (new OrderObserver())->updated($unassigned); + + $silent = new FleetOpsOrderAssignmentObserverOrder(); + $silent->setRawAttributes(['uuid' => 'order-uuid', 'driver_assigned_uuid' => 'driver-new'], true); + $silent->silentDriverAssignment = true; + + (new OrderObserver())->updated($silent); + + expect($unassigned->events)->toBe(['unassigned:driver-old', 'assigned:']) + ->and($silent->events)->toBe([]); +}); + +test('assign driver relies on the observer instead of notifying before the save', function () { + $source = file_get_contents(dirname(__DIR__, 3) . '/src/Models/Order.php'); + preg_match('/public function assignDriver\(.*?\n }\n/s', $source, $matches); + + expect($matches[0] ?? '')->not->toContain('notifyDriverAssigned()') + ->toContain('$this->silentDriverAssignment = (bool) $silent;'); +}); diff --git a/server/tests/Unit/Support/OrderOperationsTest.php b/server/tests/Unit/Support/OrderOperationsTest.php index 89ee3a7c2..6051132ee 100644 --- a/server/tests/Unit/Support/OrderOperationsTest.php +++ b/server/tests/Unit/Support/OrderOperationsTest.php @@ -377,3 +377,18 @@ function fleetopsOrderOpsOrder(SQLiteConnection $connection, array $order = [], ->and(OrderEdit::entitiesMode('sync'))->toBe('replace') ->and(OrderEdit::entitiesMode(null))->toBe('append'); }); + +test('a driver taken off an order is told and loses it as their current job', function () { + $connection = fleetopsOrderOpsBoot(); + $order = fleetopsOrderOpsOrder($connection); + + expect($order->notifyDriverUnassigned('driver-1'))->toBeTrue() + ->and($connection->table('drivers')->where('uuid', 'driver-1')->value('current_job_uuid'))->toBeNull() + ->and($order->notifyDriverUnassigned('missing-driver'))->toBeFalse(); + + $events = fleetopsOrderOpsEvents()->of(Fleetbase\FleetOps\Events\OrderDriverUnassigned::class); + expect($events)->toHaveCount(1) + ->and($events[0]->broadcastAs())->toBe('order.driver_unassigned') + ->and($events[0]->channelNames)->toBe(['driver.driver_one', 'driver.driver-1', 'company.company-1']) + ->and($events[0]->broadcastWith()['data'])->toBe(['id' => 'order_one', 'driver' => 'driver_one']); +}); From ea06435ac85dc12a3895e29aa6710eeb84aa3aaf Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:32:47 +0800 Subject: [PATCH 3/5] feat(orders): driver offers with accept, decline and expiry 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`. --- .../Console/Commands/ExpireOrderOffers.php | 158 ++++++++++++++++ server/src/Events/OrderOfferChanged.php | 73 ++++++++ .../Controllers/Api/v1/OrderController.php | 156 +++++++++++++++- .../Internal/v1/DriverController.php | 14 +- .../Internal/v1/OrderController.php | 12 +- server/src/Http/Resources/v1/Index/Order.php | 1 + server/src/Http/Resources/v1/Order.php | 2 + server/src/Models/Order.php | 168 ++++++++++++++++++ .../src/Notifications/DriverNotAccepted.php | 56 ++++++ .../src/Notifications/OrderOfferDeclined.php | 59 ++++++ server/src/Observers/OrderObserver.php | 11 ++ .../src/Providers/FleetOpsServiceProvider.php | 4 + server/src/routes.php | 2 + .../tests/ApiOrderControllerContractsTest.php | 72 ++++++++ .../Internal/OrderControllerHelpersTest.php | 6 +- .../OrderChangeNotificationsTest.php | 31 ++++ .../Observers/OrderAssignmentObserverTest.php | 15 ++ .../Unit/Support/OrderOperationsTest.php | 73 ++++++++ 18 files changed, 904 insertions(+), 9 deletions(-) create mode 100644 server/src/Console/Commands/ExpireOrderOffers.php create mode 100644 server/src/Events/OrderOfferChanged.php create mode 100644 server/src/Notifications/DriverNotAccepted.php create mode 100644 server/src/Notifications/OrderOfferDeclined.php diff --git a/server/src/Console/Commands/ExpireOrderOffers.php b/server/src/Console/Commands/ExpireOrderOffers.php new file mode 100644 index 000000000..9c9374432 --- /dev/null +++ b/server/src/Console/Commands/ExpireOrderOffers.php @@ -0,0 +1,158 @@ +option('days')); + $perChunk = max(50, (int) $this->option('chunk')); + $dryRun = (bool) $this->option('dry'); + $lock = null; + + if (!$this->option('no-lock')) { + $lock = Cache::lock('fleetops:expire-offers', 300); + if (!$lock->get()) { + $this->warn('Another offer expiry run appears to be in progress.'); + + return self::SUCCESS; + } + } + + try { + $now = Carbon::now(); + $expired = 0; + + $this->ordersQuery($now->copy()->subDays($days))->orderBy('id')->chunkById($perChunk, function ($orders) use ($now, $dryRun, &$expired) { + foreach ($orders as $order) { + if ($this->expire($order, $now, $dryRun)) { + $expired++; + } + } + }); + + $this->info(($dryRun ? '[Dry Run] ' : '') . "Offers expired: {$expired}"); + + return self::SUCCESS; + } finally { + $lock?->release(); + } + } + + /** + * Orders with an open offer that are not finished or under way. + */ + protected function ordersQuery(Carbon $cutoff) + { + return Order::query() + ->withoutGlobalScopes() + ->where('meta->offer->status', Order::OFFER_OFFERED) + ->whereNotIn('status', ['completed', 'canceled', 'expired']) + ->where(fn ($query) => $query->whereNull('started')->orWhere('started', false)) + ->whereNull('deleted_at') + ->whereNotNull('company_uuid') + ->where('updated_at', '>=', $cutoff); + } + + /** + * Expire the order's offer when it has run out. Returns whether it was expired. + */ + public function expire(Order $order, Carbon $now, bool $dryRun = false): bool + { + $offer = $order->getOffer(); + $expiresAt = data_get($offer, 'expires_at'); + + if (data_get($offer, 'status') !== Order::OFFER_OFFERED || !$expiresAt || Carbon::parse($expiresAt)->isAfter($now)) { + return false; + } + + // An offer left behind on an order that no longer has a driver is not chased + if (!$order->driver_assigned_uuid) { + if (!$dryRun) { + $order->setMeta('offer', null); + $order->saveQuietly(); + } + + return false; + } + + if ($dryRun) { + return true; + } + + session(['company' => $order->company_uuid]); + + $order->expireOffer(); + $this->raiseAlert($order, (array) $offer); + $this->broadcast($order); + + return true; + } + + protected function raiseAlert(Order $order, array $offer): void + { + $metaKey = 'operational_alerts.' . self::ALERT_KEY; + $context = [ + 'driver' => $offer['driver'] ?? null, + 'offered_at' => $offer['offered_at'] ?? null, + 'expires_at' => $offer['expires_at'] ?? null, + ]; + + // once per offer: a later offer to another driver may alert again + if (data_get($order->getMeta($metaKey), 'context.offered_at') === $context['offered_at']) { + return; + } + + try { + NotificationRegistry::notify(DriverNotAccepted::class, $order, $context); + } catch (\Throwable $exception) { + Log::warning('Unable to raise driver_not_accepted alert', ['order' => $order->public_id, 'error' => $exception->getMessage()]); + } + + $order->setMeta($metaKey, [ + 'notified_at' => Carbon::now()->toDateTimeString(), + 'context' => $context, + ]); + $order->saveQuietly(); + $order->invalidateApiCache(); + } + + protected function broadcast(Order $order): void + { + try { + event(new OrderOfferChanged($order, Order::OFFER_EXPIRED)); + } catch (\Throwable $exception) { + Log::warning('Unable to broadcast order.offer_expired', ['order' => $order->public_id, 'error' => $exception->getMessage()]); + } + } +} diff --git a/server/src/Events/OrderOfferChanged.php b/server/src/Events/OrderOfferChanged.php new file mode 100644 index 000000000..08c82cd5f --- /dev/null +++ b/server/src/Events/OrderOfferChanged.php @@ -0,0 +1,73 @@ +getMeta('offer', []); + + $this->eventId = uniqid('event_'); + $this->sentAt = Carbon::now()->toDateTimeString(); + $this->status = $status; + $this->channelNames = array_values(array_filter([ + 'order.' . $order->public_id, + 'order.' . $order->uuid, + $order->company_uuid ? 'company.' . $order->company_uuid : null, + ])); + $this->data = [ + 'id' => $order->public_id, + 'driver' => $offer['driver'] ?? null, + 'offer' => $offer ?: null, + ]; + } + + public function broadcastOn(): array + { + return array_map(fn (string $name) => new Channel($name), $this->channelNames); + } + + public function broadcastAs(): string + { + return 'order.offer_' . $this->status; + } + + public function broadcastWith(): array + { + return [ + 'id' => $this->eventId, + 'api_version' => config('api.version'), + 'event' => $this->broadcastAs(), + 'created_at' => $this->sentAt, + 'data' => $this->data, + ]; + } +} diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 4f4d059da..8773bd2b0 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1; use Fleetbase\FleetOps\Events\OrderDispatchFailed; +use Fleetbase\FleetOps\Events\OrderOfferChanged; use Fleetbase\FleetOps\Events\OrderStarted; use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException; use Fleetbase\FleetOps\Exceptions\OrderEditLockedException; @@ -25,6 +26,7 @@ use Fleetbase\FleetOps\Models\Proof; use Fleetbase\FleetOps\Models\ServiceQuote; use Fleetbase\FleetOps\Models\Waypoint; +use Fleetbase\FleetOps\Notifications\OrderOfferDeclined; use Fleetbase\FleetOps\Support\OrderEdit; use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops; use Fleetbase\FleetOps\Support\TrackingCode; @@ -34,7 +36,9 @@ use Fleetbase\Models\Company; use Fleetbase\Models\File; use Fleetbase\Models\Setting; +use Fleetbase\Models\User; use Fleetbase\Support\Auth; +use Fleetbase\Support\NotificationRegistry; use Fleetbase\Support\TemplateString; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -1062,9 +1066,15 @@ public function startOrder(string $id, Request $request) return response()->apiError('Order has already started.'); } - // if the order is adhoc and the parameter of `assign` is set with a valid driver id, assign the driver and continue + // if the order is adhoc and the parameter of `assign` is set with a valid driver id, assign the driver and continue; + // the driver took the order themselves, so their offer starts accepted if ($order->adhoc && $assignAdhocDriver && Str::startsWith($assignAdhocDriver, 'driver_')) { - $order->assignDriver($assignAdhocDriver, true); + $order->acceptOfferOnAssign = true; + try { + $order->assignDriver($assignAdhocDriver, true); + } finally { + $order->acceptOfferOnAssign = false; + } } /** @var \Fleetbase\Models\Driver */ @@ -1096,9 +1106,10 @@ public function startOrder(string $id, Request $request) return response()->apiError('Order has not been dispatched yet and cannot be started.'); } - // set order to started + // set order to started; starting the order answers an open offer $order->started = true; $order->started_at = now(); + $order->acceptOffer(false); $order->save(); // trigger start event @@ -1394,6 +1405,145 @@ public function cancelOrder(string $id) return $this->orderResource($order); } + /** + * The assigned driver accepts the order offered to them. An adhoc order that + * has no driver yet is assigned to the accepting driver. + * + * @return \Fleetbase\Http\Resources\v1\Order + */ + public function acceptOrder(string $id, Request $request) + { + try { + $order = $this->findOrder($id, ['driverAssigned']); + } catch (ModelNotFoundException $exception) { + return $this->apiError('Order resource not found.', 404); + } + + $driver = $this->offerDriverFromRequest($request); + if (!$driver) { + return $this->apiError('No driver found to accept the order.', 404); + } + + if ($order->editPolicy()->isTerminal()) { + return $this->apiError('This order can no longer be accepted.', 422); + } + + if (!$order->driver_assigned_uuid && $order->adhoc) { + $order->acceptOfferOnAssign = true; + try { + $order->assignDriver($driver, true); + } finally { + $order->acceptOfferOnAssign = false; + } + } elseif (!$order->isDriver($driver)) { + return $this->apiError('This order is not offered to you.', 403); + } elseif (!$order->acceptOffer() && $order->offerStatus() !== Order::OFFER_ACCEPTED) { + // no offer recorded (assigned before offers existed): record it as accepted + $order->acceptOfferOnAssign = true; + $order->syncOfferWithAssignment(); + $order->acceptOfferOnAssign = false; + $order->saveQuietly(); + $order->invalidateApiCache(); + } + + $this->broadcastOfferChange($order, Order::OFFER_ACCEPTED); + + return $this->orderResource($order->refresh()); + } + + /** + * The assigned driver declines the order offered to them: the order is + * unassigned and dispatch is told. + * + * @return \Fleetbase\Http\Resources\v1\Order + */ + public function declineOrder(string $id, Request $request) + { + try { + $order = $this->findOrder($id, ['driverAssigned']); + } catch (ModelNotFoundException $exception) { + return $this->apiError('Order resource not found.', 404); + } + + $driver = $this->offerDriverFromRequest($request); + if (!$driver) { + return $this->apiError('No driver found to decline the order.', 404); + } + + if (!$order->isDriver($driver)) { + return $this->apiError('This order is not offered to you.', 403); + } + + if ($order->started) { + return $this->apiError('This order has already been started and cannot be declined.', 422); + } + + $reason = $this->reasonFromRequest($request); + $order->declineOffer($reason); + + $this->broadcastOfferChange($order, Order::OFFER_DECLINED); + $this->notifyOfferDeclined($order, $driver, $reason); + + return $this->orderResource($order->refresh()); + } + + /** + * The free-text `reason` sent with a cancel or decline, trimmed to 1000 characters. + */ + protected function reasonFromRequest(Request $request): ?string + { + $reason = $request->input('reason'); + if (!is_string($reason) || trim($reason) === '') { + return null; + } + + return Str::limit(trim($reason), 1000, ''); + } + + /** + * The driver answering an offer: the `driver` named in the request, or the + * driver profile of the authenticated user. + */ + protected function offerDriverFromRequest(Request $request): ?Driver + { + if ($request->filled('driver')) { + return $this->findDriverByPublicId($request->input('driver')); + } + + $user = $request->user(); + if (!$user instanceof User) { + $user = User::where('uuid', session('user'))->first(); + } + + if (!$user) { + return null; + } + + return Driver::where(['user_uuid' => $user->uuid, 'company_uuid' => $this->sessionCompany()])->first(); + } + + protected function broadcastOfferChange(Order $order, string $status): void + { + try { + event(new OrderOfferChanged($order, $status)); + } catch (\Throwable $exception) { + Log::warning('Unable to broadcast order offer change', ['order' => $order->public_id, 'error' => $exception->getMessage()]); + } + } + + protected function notifyOfferDeclined(Order $order, Driver $driver, ?string $reason): void + { + try { + NotificationRegistry::notify(OrderOfferDeclined::class, $order, [ + 'driver' => $driver->public_id, + 'driver_name' => $driver->name, + 'reason' => $reason, + ]); + } catch (\Throwable $exception) { + Log::warning('Unable to notify dispatch of declined order', ['order' => $order->public_id, 'error' => $exception->getMessage()]); + } + } + /** * Mark a specific place (waypoint, pickup, or drop-off) as the current * destination for an order payload. diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index 6e4dcc212..3847787ef 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -598,12 +598,20 @@ protected function clearDriverAssignmentsForOrders($orders): void Order::invalidateApiCacheManually(session('company')); // The bulk update bypasses the order observer, so the driver taken off each - // order is told here + // order is told here and the offer made to them is dropped foreach ($orders as $order) { $previousDriverUuid = $order->driver_assigned_uuid; - if ($previousDriverUuid) { - $order->notifyDriverUnassigned($previousDriverUuid); + if (!$previousDriverUuid) { + continue; } + + $order->driver_assigned_uuid = null; + $order->syncOfferWithAssignment(); + if ($order->isDirty('meta')) { + $order->saveQuietly(); + } + $order->syncOriginal(); + $order->notifyDriverUnassigned($previousDriverUuid); } } diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 2da065c52..970460d3d 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -797,6 +797,15 @@ protected function assignDriverToOrders($orderUuids, Driver $driver): void foreach ($replaced as $order) { $order->notifyDriverUnassigned($order->driver_assigned_uuid); } + + // The bulk update bypasses the model, so each order's driver offer is started here + $this->scopedToCompany(Order::whereIn('uuid', $orderUuids))->get()->each(function (Order $order) use ($driver) { + $order->setRelation('driverAssigned', $driver); + $order->syncOfferWithAssignment(); + if ($order->isDirty('meta')) { + $order->saveQuietly(); + } + }); } protected function dispatchBulkAssignedDriverNotification(array $orderUuids, Driver $driver): void @@ -863,9 +872,10 @@ public function start(Request $request) return response()->error('No driver assigned to order.'); } - // set order to started + // set order to started; starting the order answers an open offer $order->started = true; $order->started_at = now(); + $order->acceptOffer(false); $order->save(); // trigger start event diff --git a/server/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php index 1b413f22d..39afca97a 100644 --- a/server/src/Http/Resources/v1/Index/Order.php +++ b/server/src/Http/Resources/v1/Index/Order.php @@ -113,6 +113,7 @@ public function toArray($request): array 'is_scheduled' => $this->when($isInternal, $this->is_scheduled), 'transaction_amount' => $this->transaction_amount, 'currency' => $this->transaction_currency, + 'offer' => data_get($this, 'meta.offer'), // Timestamps 'scheduled_at' => $this->scheduled_at, diff --git a/server/src/Http/Resources/v1/Order.php b/server/src/Http/Resources/v1/Order.php index 6ed174612..43ac5dbed 100644 --- a/server/src/Http/Resources/v1/Order.php +++ b/server/src/Http/Resources/v1/Order.php @@ -99,6 +99,7 @@ public function toArray($request): array return $this->resource->eta ?? ($tracker ? $tracker->eta() : null); }), 'meta' => data_get($this, 'meta', Utils::createObject()), + 'offer' => data_get($this, 'meta.offer'), 'dispatched_at' => $this->dispatched_at, 'started_at' => $this->started_at, 'scheduled_at' => $this->scheduled_at, @@ -207,6 +208,7 @@ public function toWebhookPayload() 'status' => $this->status, 'adhoc' => $this->adhoc, 'meta' => data_get($this, 'meta', Utils::createObject()), + 'offer' => data_get($this, 'meta.offer'), 'dispatched_at' => $this->dispatched_at, 'started_at' => $this->started_at, 'scheduled_at' => $this->scheduled_at, diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 0370a7240..b581f7204 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -18,6 +18,7 @@ use Fleetbase\FleetOps\Traits\HasTrackingNumber; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Model; +use Fleetbase\Models\Setting; use Fleetbase\Models\Transaction; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasApiModelCache; @@ -280,11 +281,30 @@ class Order extends Model '_key', ]; + /** + * Offer statuses kept in `meta.offer.status`. + */ + public const OFFER_OFFERED = 'offered'; + public const OFFER_ACCEPTED = 'accepted'; + public const OFFER_DECLINED = 'declined'; + public const OFFER_EXPIRED = 'expired'; + + /** + * Minutes a driver has to accept an order offered to them, unless the company + * sets `fleetops.offer_timeout_minutes`. Zero means offers never expire. + */ + public const DEFAULT_OFFER_TIMEOUT_MINUTES = 5; + /** * Set for one save: the driver assignment being saved sends no assignment notification. */ public bool $silentDriverAssignment = false; + /** + * Set for one save: the driver being assigned has already accepted the order (adhoc start). + */ + public bool $acceptOfferOnAssign = false; + /** * Set for one save: the driver taken off the order is not sent an unassigned push (they declined it themselves). */ @@ -1521,6 +1541,154 @@ public function editPolicy(): OrderEditPolicy return OrderEditPolicy::for($this); } + /** + * The driver offer on the order (`meta.offer`), or null when there is none. + */ + public function getOffer(): ?array + { + $offer = $this->getMeta('offer'); + + return is_array($offer) && !empty($offer) ? $offer : null; + } + + /** + * The status of the driver offer on the order, or null when there is none. + */ + public function offerStatus(): ?string + { + return data_get($this->getOffer(), 'status'); + } + + /** + * Minutes a driver has to accept an offer, from the company setting + * `fleetops.offer_timeout_minutes` (default 5). Zero disables expiry. + */ + public function offerTimeoutMinutes(): int + { + try { + $minutes = Setting::lookupForCompany($this->company_uuid ?? session('company'), 'fleetops.offer_timeout_minutes', static::DEFAULT_OFFER_TIMEOUT_MINUTES); + } catch (\Throwable) { + $minutes = static::DEFAULT_OFFER_TIMEOUT_MINUTES; + } + + return is_numeric($minutes) ? max(0, (int) $minutes) : static::DEFAULT_OFFER_TIMEOUT_MINUTES; + } + + /** + * Keep `meta.offer` in step with the driver assignment being saved: a newly + * assigned driver gets a fresh offer (accepted straight away when they took + * the order themselves), and removing the driver drops an open offer. + * Only sets attributes; the save in progress persists them. + */ + public function syncOfferWithAssignment(): self + { + $driverUuid = $this->driver_assigned_uuid; + + if (!$driverUuid) { + if (in_array($this->offerStatus(), [static::OFFER_OFFERED, static::OFFER_ACCEPTED], true)) { + $this->setMeta('offer', null); + } + + return $this; + } + + if ($this->exists && $this->editPolicy()->isTerminal()) { + return $this; + } + + $driverPublicId = data_get($this->relationLoaded('driverAssigned') ? $this->getRelation('driverAssigned') : null, 'public_id'); + if (!$driverPublicId || data_get($this->getRelation('driverAssigned'), 'uuid') !== $driverUuid) { + try { + $driverPublicId = Driver::where('uuid', $driverUuid)->withoutGlobalScopes()->value('public_id'); + } catch (\Throwable) { + $driverPublicId = null; + } + } + + $now = Carbon::now(); + $offer = [ + 'driver' => $driverPublicId, + 'offered_at' => $now->toIso8601ZuluString(), + 'expires_at' => null, + 'accepted_at' => null, + 'declined_at' => null, + 'status' => static::OFFER_OFFERED, + ]; + + if ($this->acceptOfferOnAssign) { + $offer['accepted_at'] = $offer['offered_at']; + $offer['status'] = static::OFFER_ACCEPTED; + } elseif ($minutes = $this->offerTimeoutMinutes()) { + $offer['expires_at'] = $now->copy()->addMinutes($minutes)->toIso8601ZuluString(); + } + + $this->setMeta('offer', $offer); + + return $this; + } + + /** + * Mark the open offer accepted. Returns false when there is no open offer. + */ + public function acceptOffer(bool $save = true): bool + { + $offer = $this->getOffer(); + if (!$offer || !in_array($offer['status'] ?? null, [static::OFFER_OFFERED, static::OFFER_EXPIRED], true)) { + return false; + } + + $offer['status'] = static::OFFER_ACCEPTED; + $offer['accepted_at'] = Carbon::now()->toIso8601ZuluString(); + $this->setMeta('offer', $offer); + + if ($save) { + $this->saveQuietly(); + $this->invalidateApiCache(); + } + + return true; + } + + /** + * Record that the driver declined the offer and take the order off them. + */ + public function declineOffer(?string $reason = null): self + { + $offer = $this->getOffer() ?? ['driver' => data_get($this, 'driverAssigned.public_id'), 'offered_at' => null, 'expires_at' => null, 'accepted_at' => null]; + + $offer['status'] = static::OFFER_DECLINED; + $offer['declined_at'] = Carbon::now()->toIso8601ZuluString(); + if ($reason) { + $offer['reason'] = $reason; + } + + $this->setMeta('offer', $offer); + $this->quietDriverUnassignment = true; + $this->driver_assigned_uuid = null; + $this->save(); + $this->quietDriverUnassignment = false; + + return $this; + } + + /** + * Mark an open offer expired. Returns false when there is no open offer. + */ + public function expireOffer(): bool + { + $offer = $this->getOffer(); + if (!$offer || ($offer['status'] ?? null) !== static::OFFER_OFFERED) { + return false; + } + + $offer['status'] = static::OFFER_EXPIRED; + $this->setMeta('offer', $offer); + $this->saveQuietly(); + $this->invalidateApiCache(); + + return true; + } + /** * Updates the activity of the order. * Updates the order status and inserts a new activity based on the provided activity details. diff --git a/server/src/Notifications/DriverNotAccepted.php b/server/src/Notifications/DriverNotAccepted.php new file mode 100644 index 000000000..d106ec4bf --- /dev/null +++ b/server/src/Notifications/DriverNotAccepted.php @@ -0,0 +1,56 @@ +order->tracking ?? $this->order->public_id; + + return (new MailMessage()) + ->subject("Driver has not accepted {$orderId}") + ->line($this->message()) + ->line('Please contact the driver or reassign the order.'); + } + + public function toArray($notifiable): array + { + return [ + 'event' => 'order.driver_not_accepted', + 'order_id' => $this->order->public_id, + 'order_uuid' => $this->order->uuid, + 'context' => $this->context, + 'message' => $this->message(), + ]; + } + + protected function message(): string + { + return sprintf('The driver has not accepted order %s within the offer window.', $this->order->tracking ?? $this->order->public_id); + } +} diff --git a/server/src/Notifications/OrderOfferDeclined.php b/server/src/Notifications/OrderOfferDeclined.php new file mode 100644 index 000000000..1fb14a80c --- /dev/null +++ b/server/src/Notifications/OrderOfferDeclined.php @@ -0,0 +1,59 @@ +order->tracking ?? $this->order->public_id; + + return (new MailMessage()) + ->subject("Driver declined {$orderId}") + ->line($this->message()) + ->line('The order is unassigned and needs a driver.'); + } + + public function toArray($notifiable): array + { + return [ + 'event' => 'order.offer_declined', + 'order_id' => $this->order->public_id, + 'order_uuid' => $this->order->uuid, + 'context' => $this->context, + 'message' => $this->message(), + ]; + } + + protected function message(): string + { + $driver = $this->context['driver_name'] ?? 'The driver'; + $reason = $this->context['reason'] ?? null; + + return sprintf('%s declined order %s%s', $driver, $this->order->tracking ?? $this->order->public_id, $reason ? ': ' . $reason : '.'); + } +} diff --git a/server/src/Observers/OrderObserver.php b/server/src/Observers/OrderObserver.php index 45c9261bd..c2c4c21ed 100644 --- a/server/src/Observers/OrderObserver.php +++ b/server/src/Observers/OrderObserver.php @@ -34,6 +34,17 @@ public function created(Order $order) $this->invalidateCache($order); } + /** + * Handle the Order "saving" event: a driver assignment being saved starts + * (or clears) the driver offer in the same write. + */ + public function saving(Order $order): void + { + if ($order->isDirty('driver_assigned_uuid')) { + $order->syncOfferWithAssignment(); + } + } + /** * Handle the Order "updating" event. * diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index bb24c8932..cd90f69e5 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -75,6 +75,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider \Fleetbase\FleetOps\Console\Commands\ProcessMaintenanceTriggers::class, \Fleetbase\FleetOps\Console\Commands\SendMaintenanceReminders::class, \Fleetbase\FleetOps\Console\Commands\ProcessOperationalAlerts::class, + \Fleetbase\FleetOps\Console\Commands\ExpireOrderOffers::class, \Fleetbase\FleetOps\Console\Commands\SyncTelematics::class, \Fleetbase\FleetOps\Console\Commands\DrainTelematicInbox::class, \Fleetbase\FleetOps\Console\Commands\PruneTelematicsData::class, @@ -150,6 +151,7 @@ public function boot() $schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb(); $schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb(); $schedule->command('fleetops:process-operational-alerts')->everyMinute()->withoutOverlapping()->storeOutputInDb(); + $schedule->command('fleetops:expire-offers')->everyMinute()->withoutOverlapping(); // These commands dispatch bounded jobs. A scheduler restart must not // leave the default 24-hour overlap lease blocking telemetry recovery. $schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping(2)->storeOutputInDb(); @@ -252,6 +254,8 @@ public function registerNotifications() \Fleetbase\FleetOps\Notifications\LateDeparture::class, \Fleetbase\FleetOps\Notifications\RouteDeviation::class, \Fleetbase\FleetOps\Notifications\ProlongedStoppage::class, + \Fleetbase\FleetOps\Notifications\DriverNotAccepted::class, + \Fleetbase\FleetOps\Notifications\OrderOfferDeclined::class, \Fleetbase\FleetOps\Notifications\OrderChanged::class, \Fleetbase\FleetOps\Notifications\OrderUnassigned::class, ]); diff --git a/server/src/routes.php b/server/src/routes.php index 8060b5512..174069dc7 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -210,6 +210,8 @@ function ($router) { $router->match(['post', 'patch'], '{id}/dispatch', 'OrderController@dispatchOrder'); $router->post('{id}/start', 'OrderController@startOrder'); $router->delete('{id}/cancel', 'OrderController@cancelOrder'); + $router->post('{id}/accept', 'OrderController@acceptOrder'); + $router->post('{id}/decline', 'OrderController@declineOrder'); $router->match(['post', 'patch'], '{id}/update-activity', 'OrderController@updateActivity'); $router->post('{id}/complete', 'OrderController@completeOrder'); $router->get('{id}/next-activity', 'OrderController@getNextActivity'); diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index 58c473fde..33435dbfc 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -72,6 +72,20 @@ protected function lockedEditResponse(OrderEditLockedException $exception) return ['apiError' => $exception->getMessage(), 'status' => 422, 'locked' => $exception->locked]; } + public ?Fleetbase\FleetOps\Models\Driver $offerDriver = null; + + public array $offerNotifications = []; + + protected function offerDriverFromRequest(Request $request): ?Fleetbase\FleetOps\Models\Driver + { + return $this->offerDriver; + } + + protected function notifyOfferDeclined(Order $order, Fleetbase\FleetOps\Models\Driver $driver, ?string $reason): void + { + $this->offerNotifications[] = [$order->uuid, $driver->public_id, $reason]; + } + protected function drivingDistanceAndTime(mixed $origin, mixed $destination): mixed { return $this->matrix ?? (object) ['distance' => 1200, 'time' => 360]; @@ -438,6 +452,22 @@ public function cancel() return $this; } + public bool $declinedForTest = false; + + public ?string $declineReasonForTest = null; + + public function declineOffer(?string $reason = null): Order + { + $this->declinedForTest = true; + $this->declineReasonForTest = $reason; + $this->setMeta('offer', ['status' => Order::OFFER_DECLINED]); + + return $this; + } + + public function invalidateApiCache(): void + { + } public function tracker(): OrderTracker { @@ -1219,3 +1249,45 @@ public function isString(string $key): bool ->and($controller->edit->guarded[0]['input'])->toMatchArray(['cod_amount' => '2500']) ->and($controller->edit->guarded[0]['entities_mode'])->toBe('append'); }); + +test('api order controller accepts and declines offers for the assigned driver only', function () { + $driver = new FleetOpsApiOrderDriverFake(); + $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_offer', 'name' => 'Dee'], true); + + $order = new FleetOpsApiOrderCrudFake(['status' => 'created']); + $order->setRawAttributes(array_merge($order->getAttributes(), ['driver_assigned_uuid' => 'driver-uuid'])); + $order->setMeta('offer', ['driver' => 'driver_offer', 'status' => Order::OFFER_OFFERED, 'offered_at' => '2026-10-13T12:00:00Z', 'expires_at' => '2026-10-13T12:05:00Z']); + + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = $order; + $controller->offerDriver = $driver; + + $accepted = $controller->acceptOrder('order-public', new Request()); + + expect($accepted)->toBe(['resource' => 'order', 'order' => $order]) + ->and($order->offerStatus())->toBe(Order::OFFER_ACCEPTED) + ->and($order->getOffer()['accepted_at'])->not->toBeNull(); + + $declined = $controller->declineOrder('order-public', new Request(['reason' => 'Too far'])); + + expect($declined)->toBe(['resource' => 'order', 'order' => $order]) + ->and($order->declinedForTest)->toBeTrue() + ->and($order->declineReasonForTest)->toBe('Too far') + ->and($controller->offerNotifications)->toBe([['order-uuid', 'driver_offer', 'Too far']]); + + $other = new FleetOpsApiOrderDriverFake(); + $other->setRawAttributes(['uuid' => 'other-uuid', 'public_id' => 'driver_other'], true); + $controller->offerDriver = $other; + + expect($controller->acceptOrder('order-public', new Request()))->toBe(['apiError' => 'This order is not offered to you.', 'status' => 403]) + ->and($controller->declineOrder('order-public', new Request()))->toBe(['apiError' => 'This order is not offered to you.', 'status' => 403]); + + $controller->offerDriver = null; + expect($controller->acceptOrder('order-public', new Request()))->toBe(['apiError' => 'No driver found to accept the order.', 'status' => 404]); + + $done = new FleetOpsApiOrderCrudFake(['status' => 'canceled']); + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = $done; + $controller->offerDriver = $driver; + expect($controller->acceptOrder('order-public', new Request()))->toBe(['apiError' => 'This order can no longer be accepted.', 'status' => 422]); +}); diff --git a/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php b/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php index 1760fbc3f..a94e37dba 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php @@ -96,7 +96,7 @@ public function __call($method, $arguments) $schema = $connection->getSchemaBuilder(); $tables = [ - 'orders' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'tracking_number_uuid', 'driver_assigned_uuid', 'status'], + 'orders' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'tracking_number_uuid', 'driver_assigned_uuid', 'status', 'meta'], 'contacts' => ['uuid', 'public_id', 'company_uuid', 'name', 'type'], 'vendors' => ['uuid', 'public_id', 'company_uuid', 'name'], 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'], @@ -168,7 +168,9 @@ public function __call($method, $arguments) (new FleetOpsInternalOrderHelpersProbe())->callProtected('assignDriverToOrders', ['order-1', 'order-2'], $driver); - expect($connection->table('orders')->where('driver_assigned_uuid', 'driver-1')->count())->toBe(2); + // the bulk update bypasses the model, so each order's driver offer is started explicitly + expect($connection->table('orders')->where('driver_assigned_uuid', 'driver-1')->count())->toBe(2) + ->and(json_decode($connection->table('orders')->where('uuid', 'order-1')->value('meta'), true)['offer']['status'] ?? null)->toBe('offered'); }); test('dispatch domain event helper emits and returns the event', function () { diff --git a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php index 0682d86ec..f2d10b54b 100644 --- a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php +++ b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php @@ -54,3 +54,34 @@ function fleetopsOrderChangeNotificationOrder(): Order ->and($notification->via(null))->toBe([FcmChannel::class, ApnChannel::class]) ->and($notification->toArray()['event'])->toBe('order.unassigned_notification'); }); + +test('offer alerts describe the declined or unanswered offer for dispatch', function () { + $order = fleetopsOrderChangeNotificationOrder(); + $declined = new OrderOfferDeclined($order, ['driver' => 'driver_one', 'driver_name' => 'Dee', 'reason' => 'Too far']); + $silent = new DriverNotAccepted($order, ['driver' => 'driver_one']); + + expect($declined->toArray(null))->toMatchArray([ + 'event' => 'order.offer_declined', + 'order_id' => 'order_change', + 'message' => 'Dee declined order FLB-CHANGE-1: Too far', + ]) + ->and($declined->via(null))->toBe(['mail', 'database']) + ->and($silent->toArray(null))->toMatchArray([ + 'event' => 'order.driver_not_accepted', + 'order_id' => 'order_change', + 'context' => ['driver' => 'driver_one'], + ]); +}); + +test('offer changes broadcast as order offer status on the order and company channels', function () { + $event = new OrderOfferChanged(fleetopsOrderChangeNotificationOrder(), Order::OFFER_EXPIRED); + + expect($event->broadcastAs())->toBe('order.offer_expired') + ->and(array_map(fn ($channel) => $channel->name, $event->broadcastOn()))->toBe(['order.order_change', 'order.order-uuid', 'company.company-1']) + ->and($event->broadcastWith())->toMatchArray(['event' => 'order.offer_expired']) + ->and($event->broadcastWith()['data'])->toBe([ + 'id' => 'order_change', + 'driver' => 'driver_one', + 'offer' => ['driver' => 'driver_one', 'status' => 'expired', 'expires_at' => '2026-10-13T12:05:00Z'], + ]); +}); diff --git a/server/tests/Unit/Observers/OrderAssignmentObserverTest.php b/server/tests/Unit/Observers/OrderAssignmentObserverTest.php index 1c61273d6..d98aabb37 100644 --- a/server/tests/Unit/Observers/OrderAssignmentObserverTest.php +++ b/server/tests/Unit/Observers/OrderAssignmentObserverTest.php @@ -98,6 +98,21 @@ public function orderChanged(Order $order): bool ->and($silent->events)->toBe([]); }); +test('saving a driver assignment starts the offer in the same write', function () { + $order = new FleetOpsOrderAssignmentObserverOrder(); + $order->driverDirty = true; + + (new OrderObserver())->saving($order); + + $unchanged = new FleetOpsOrderAssignmentObserverOrder(); + $unchanged->driverDirty = false; + + (new OrderObserver())->saving($unchanged); + + expect($order->events)->toBe(['offer']) + ->and($unchanged->events)->toBe([]); +}); + test('assign driver relies on the observer instead of notifying before the save', function () { $source = file_get_contents(dirname(__DIR__, 3) . '/src/Models/Order.php'); preg_match('/public function assignDriver\(.*?\n }\n/s', $source, $matches); diff --git a/server/tests/Unit/Support/OrderOperationsTest.php b/server/tests/Unit/Support/OrderOperationsTest.php index 6051132ee..d76e24fe3 100644 --- a/server/tests/Unit/Support/OrderOperationsTest.php +++ b/server/tests/Unit/Support/OrderOperationsTest.php @@ -378,6 +378,79 @@ function fleetopsOrderOpsOrder(SQLiteConnection $connection, array $order = [], ->and(OrderEdit::entitiesMode(null))->toBe('append'); }); +test('assigning a driver starts an offer that is accepted declined or expired', function () { + $connection = fleetopsOrderOpsBoot(); + Carbon::setTestNow('2026-10-13 12:00:00'); + $order = fleetopsOrderOpsOrder($connection); + + $order->driver_assigned_uuid = 'driver-1'; + $order->syncOfferWithAssignment(); + + expect($order->getOffer())->toBe([ + 'driver' => 'driver_one', + 'offered_at' => '2026-10-13T12:00:00Z', + 'expires_at' => '2026-10-13T12:05:00Z', + 'accepted_at' => null, + 'declined_at' => null, + 'status' => 'offered', + ]); + $order->saveQuietly(); + + // adhoc drivers who took the order themselves start accepted + $adhoc = new Order(['driver_assigned_uuid' => 'driver-1']); + $adhoc->acceptOfferOnAssign = true; + $adhoc->syncOfferWithAssignment(); + expect($adhoc->offerStatus())->toBe('accepted') + ->and($adhoc->getOffer()['accepted_at'])->toBe('2026-10-13T12:00:00Z'); + + // the expiry command leaves an offer alone until it runs out + $command = new ExpireOrderOffers(); + expect($command->expire($order, Carbon::parse('2026-10-13 12:04:00')))->toBeFalse(); + + expect($command->expire($order, Carbon::parse('2026-10-13 12:06:00')))->toBeTrue(); + $stored = Order::where('uuid', 'order-1')->first(); + expect($stored->offerStatus())->toBe('expired') + ->and($stored->getMeta('operational_alerts.driver_not_accepted.context'))->toBe([ + 'driver' => 'driver_one', + 'offered_at' => '2026-10-13T12:00:00Z', + 'expires_at' => '2026-10-13T12:05:00Z', + ]) + ->and(fleetopsOrderOpsEvents()->of(OrderOfferChanged::class)[0]->broadcastAs())->toBe('order.offer_expired') + ->and(fleetopsOrderOpsEvents()->of(OrderOfferChanged::class)[0]->channelNames)->toBe(['order.order_one', 'order.order-1', 'company.company-1']) + ->and($command->expire($stored, Carbon::parse('2026-10-13 12:07:00')))->toBeFalse(); + + // a late acceptance still counts + expect($stored->acceptOffer())->toBeTrue() + ->and(Order::where('uuid', 'order-1')->first()->offerStatus())->toBe('accepted'); + + // declining records it and takes the order off the driver + $stored->setMeta('offer', array_merge($stored->getOffer(), ['status' => 'offered'])); + $stored->declineOffer('Too far'); + $declined = Order::where('uuid', 'order-1')->first(); + expect($declined->driver_assigned_uuid)->toBeNull() + ->and($declined->offerStatus())->toBe('declined') + ->and($declined->getOffer()['reason'])->toBe('Too far'); + + // removing the driver drops an open offer but keeps a declined one + $declined->syncOfferWithAssignment(); + expect($declined->offerStatus())->toBe('declined'); + $open = new Order(); + $open->setMeta('offer', ['status' => 'offered']); + $open->syncOfferWithAssignment(); + expect($open->getOffer())->toBeNull(); + + Carbon::setTestNow(); +}); + +test('the expiry command drops offers left on orders without a driver', function () { + $connection = fleetopsOrderOpsBoot(); + $order = fleetopsOrderOpsOrder($connection, ['meta' => json_encode(['offer' => ['driver' => 'driver_one', 'status' => 'offered', 'expires_at' => '2026-10-13T12:05:00Z']])]); + + expect((new ExpireOrderOffers())->expire($order, Carbon::parse('2026-10-13 12:06:00')))->toBeFalse() + ->and(Order::where('uuid', 'order-1')->first()->getOffer())->toBeNull() + ->and(fleetopsOrderOpsEvents()->of(OrderOfferChanged::class))->toBe([]); +}); + test('a driver taken off an order is told and loses it as their current job', function () { $connection = fleetopsOrderOpsBoot(); $order = fleetopsOrderOpsOrder($connection); From 9026c245ea2fef97b366892ba0497b6294337a65 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:36:46 +0800 Subject: [PATCH 4/5] feat(orders): cancel an order with a reason 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. --- .../Controllers/Api/v1/OrderController.php | 14 +- .../Internal/v1/OrderController.php | 7 +- .../src/Http/Requests/CancelOrderRequest.php | 3 +- server/src/Listeners/HandleOrderCanceled.php | 10 +- server/src/Models/Order.php | 30 +++- server/src/Notifications/OrderCanceled.php | 5 + .../tests/ApiOrderControllerContractsTest.php | 29 ++++ .../Internal/OrderControllerContractsTest.php | 18 ++- .../NotificationAndMailContractsTest.php | 2 +- .../Unit/Models/OrderCancelReasonTest.php | 143 ++++++++++++++++++ 10 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 server/tests/Unit/Models/OrderCancelReasonTest.php diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 8773bd2b0..fa64ba550 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -1387,8 +1387,12 @@ public function completeOrder(string $id) * * @return \Fleetbase\Http\Resources\v1\Order */ - public function cancelOrder(string $id) + public function cancelOrder(string $id, ?Request $request = null) { + // Laravel does not inject a class-typed parameter that has a default, so the + // current request is read here; the default keeps cancelOrder($id) callable. + $request = $request ?? request(); + try { $order = $this->findOrder($id); } catch (ModelNotFoundException $exception) { @@ -1400,7 +1404,13 @@ public function cancelOrder(string $id) ); } - $order->cancel(); + // the reason becomes the canceled status details and is sent with the canceled notifications + $reason = $this->reasonFromRequest($request); + if ($reason !== null) { + $order->cancelWithReason($reason); + } else { + $order->cancel(); + } return $this->orderResource($order); } diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 970460d3d..d9cc3bd46 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -612,7 +612,12 @@ public function cancel(CancelOrderRequest $request) return $this->errorResponse('No order found to cancel.'); } - $order->cancel(); + $reason = $request->input('reason'); + if (is_string($reason) && trim($reason) !== '') { + $order->cancelWithReason($reason); + } else { + $order->cancel(); + } return $this->jsonResponse( [ diff --git a/server/src/Http/Requests/CancelOrderRequest.php b/server/src/Http/Requests/CancelOrderRequest.php index ba6607783..3168734f5 100644 --- a/server/src/Http/Requests/CancelOrderRequest.php +++ b/server/src/Http/Requests/CancelOrderRequest.php @@ -20,7 +20,8 @@ public function authorize(): bool public function rules(): array { return [ - 'order' => 'required|exists:orders,uuid', + 'order' => 'required|exists:orders,uuid', + 'reason' => 'nullable|string|max:1000', ]; } } diff --git a/server/src/Listeners/HandleOrderCanceled.php b/server/src/Listeners/HandleOrderCanceled.php index 63b880896..4adb3a525 100644 --- a/server/src/Listeners/HandleOrderCanceled.php +++ b/server/src/Listeners/HandleOrderCanceled.php @@ -12,6 +12,11 @@ class HandleOrderCanceled implements ShouldQueue { use InteractsWithQueue; + /** + * The reason the order being handled was canceled. + */ + protected string $reason = ''; + /** * Handle the event. * @@ -21,6 +26,9 @@ class HandleOrderCanceled implements ShouldQueue */ public function handle(OrderCanceled $event) { + // the cancellation reason travels as the canceled activity's details + $this->reason = (string) ($event->activity ? $event->activity->get('details', '') : ''); + /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); if ($order->isIntegratedVendorOrder()) { @@ -50,6 +58,6 @@ protected function findAssignedDriver($order): ?Driver protected function notifyAssignedDriver(Driver $driver, $order): void { - $driver->notify(new OrderCanceledNotification($order)); + $driver->notify(new OrderCanceledNotification($order, $this->reason)); } } diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index b581f7204..36e7ebf5e 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1463,11 +1463,33 @@ public function firstDispatchWithActivity(): Order * @return mixed the result of the OrderCanceled event */ public function cancel() + { + return $this->cancelWithReason(null); + } + + /** + * Cancels the order, recording why. + * + * The reason becomes the details of the canceled tracking status and is carried on + * the OrderCanceled event's activity, which the canceled notifications read. + * + * @return mixed the result of the OrderCanceled event + */ + public function cancelWithReason(?string $reason = null) { // The canceled activity comes from the order config, which may define its own code. $this->loadMissing('orderConfig'); $orderConfig = $this->orderConfig ?? $this->config(); $canceledActivity = $orderConfig->getCanceledActivity(); + + // A reason given for the cancellation becomes the canceled status details, which + // the canceled notifications read. The config's activity itself is left untouched. + $reason = is_string($reason) ? trim($reason) : null; + $reasonActivity = null; + if ($reason !== null && $reason !== '' && $canceledActivity instanceof Activity) { + $canceledActivity = $reasonActivity = (clone $canceledActivity)->set('details', $reason); + } + $this->status = $canceledActivity->code ?? 'canceled'; $this->updateActivity($canceledActivity); @@ -1479,7 +1501,13 @@ public function cancel() } } - return dispatch(fn () => event(new OrderCanceled($this)))->afterCommit(); + // The event carries the activity when a reason was given, so the canceled + // notifications (which read the activity details) include it. + return dispatch(function () use ($reasonActivity) { + $event = new OrderCanceled($this); + $event->activity = $reasonActivity; + event($event); + })->afterCommit(); } /** diff --git a/server/src/Notifications/OrderCanceled.php b/server/src/Notifications/OrderCanceled.php index a1827295b..22dc8f743 100644 --- a/server/src/Notifications/OrderCanceled.php +++ b/server/src/Notifications/OrderCanceled.php @@ -82,6 +82,11 @@ public function __construct(Order $order, string $reason = '', ?Waypoint $waypoi $this->title = 'Order ' . $this->getTrackingNumber() . ' was canceled'; $this->message = 'Order ' . $this->getTrackingNumber() . ' has been canceled.'; $this->data = ['id' => $this->order->public_id, 'type' => 'order_canceled']; + + // apps show why the order was canceled when a reason was given + if ($reason !== '') { + $this->data['reason'] = $reason; + } } /** diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index 33435dbfc..95599e330 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -452,6 +452,16 @@ public function cancel() return $this; } + public ?string $cancelReasonForTest = null; + + public function cancelWithReason(?string $reason = null) + { + $this->cancelledForTest = true; + $this->cancelReasonForTest = $reason; + + return $this; + } + public bool $declinedForTest = false; public ?string $declineReasonForTest = null; @@ -1250,6 +1260,25 @@ public function isString(string $key): bool ->and($controller->edit->guarded[0]['entities_mode'])->toBe('append'); }); +test('api order controller cancels with a reason when one is given', function () { + $order = new FleetOpsApiOrderCrudFake(); + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = $order; + + $controller->cancelOrder('order-public', new Request(['reason' => ' Customer moved away '])); + + expect($order->cancelledForTest)->toBeTrue() + ->and($order->cancelReasonForTest)->toBe('Customer moved away'); + + $plain = new FleetOpsApiOrderCrudFake(); + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = $plain; + $controller->cancelOrder('order-public', new Request()); + + expect($plain->cancelledForTest)->toBeTrue() + ->and($plain->cancelReasonForTest)->toBeNull(); +}); + test('api order controller accepts and declines offers for the assigned driver only', function () { $driver = new FleetOpsApiOrderDriverFake(); $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_offer', 'name' => 'Dee'], true); diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php index cd970116c..9b5e62f19 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php @@ -468,6 +468,14 @@ public function cancel() return $this; } + public ?string $cancelReasonForTest = null; + + public function cancelWithReason(?string $reason = null) + { + $this->cancelReasonForTest = $reason; + + return $this->cancel(); + } public function dispatch(bool $save = true): Order { @@ -1129,7 +1137,15 @@ function fleetopsSuppressStrNullDeprecations(): Closure 'order' => 'order-uuid', ]) ->and($order->lookup_uuid)->toBe('order-uuid') - ->and($order->canceledForTest)->toBeTrue(); + ->and($order->canceledForTest)->toBeTrue() + ->and($order->cancelReasonForTest)->toBeNull(); + + $order = fleetopsInternalOrderLifecycleOrder('order-uuid', 'created'); + $controller = fleetopsInternalOrderLifecycleController([$order]); + $controller->cancel(fleetopsCancelOrderRequest(['order' => 'order-uuid', 'reason' => 'Customer asked to cancel'])); + + expect($order->canceledForTest)->toBeTrue() + ->and($order->cancelReasonForTest)->toBe('Customer asked to cancel'); $order = fleetopsInternalOrderLifecycleOrder('order-dispatch', 'created'); $controller = fleetopsInternalOrderLifecycleController([$order]); diff --git a/server/tests/NotificationAndMailContractsTest.php b/server/tests/NotificationAndMailContractsTest.php index a2f0f419c..467bd4998 100644 --- a/server/tests/NotificationAndMailContractsTest.php +++ b/server/tests/NotificationAndMailContractsTest.php @@ -543,7 +543,7 @@ public function track(Order $order, mixed $options): array 'event' => 'order.canceled_notification', 'title' => 'Order WP-TRACK-123 was canceled', 'body' => 'Order WP-TRACK-123 has been canceled. customer requested cancel', - 'data' => ['id' => 'order_public', 'type' => 'order_canceled'], + 'data' => ['id' => 'order_public', 'type' => 'order_canceled', 'reason' => 'customer requested cancel'], ]) ->and($failed->toArray())->toMatchArray([ 'event' => 'order.failed_notification', diff --git a/server/tests/Unit/Models/OrderCancelReasonTest.php b/server/tests/Unit/Models/OrderCancelReasonTest.php new file mode 100644 index 000000000..eddd2a606 --- /dev/null +++ b/server/tests/Unit/Models/OrderCancelReasonTest.php @@ -0,0 +1,143 @@ +order; } }'); +} + +use Fleetbase\FleetOps\Events\OrderCanceled; +use Fleetbase\FleetOps\Flow\Activity; +use Fleetbase\FleetOps\Listeners\HandleOrderCanceled; +use Fleetbase\FleetOps\Models\Driver; +use Fleetbase\FleetOps\Models\Order; +use Fleetbase\FleetOps\Models\OrderConfig; + +/** + * Covers canceling an order with a reason: the reason becomes the canceled + * status details and rides on the OrderCanceled event, while the config's + * activity is left untouched and a plain cancel carries no activity. + */ +class FleetOpsOrderCancelReasonOrder extends Order +{ + public array $activities = []; + + public function updateActivity(?Activity $activity = null, $proof = null): Order + { + $this->activities[] = $activity; + $this->status = $activity?->code; + + return $this; + } +} + +class FleetOpsOrderCancelReasonConfig extends OrderConfig +{ + public ?Activity $canceled = null; + + public function getCanceledActivity() + { + return $this->canceled; + } +} + +function fleetopsOrderCancelReasonOrder(): array +{ + FleetOpsOrderCancelReasonRecorder::$events = []; + + $config = new FleetOpsOrderCancelReasonConfig(); + $config->canceled = new Activity(['code' => 'canceled', 'status' => 'Order canceled', 'details' => 'Order was canceled']); + + $order = new FleetOpsOrderCancelReasonOrder(); + $order->setRawAttributes(['uuid' => 'order-uuid', 'status' => 'dispatched'], true); + $order->setRelation('orderConfig', $config); + + return [$order, $config]; +} + +test('canceling with a reason records it as the canceled details and on the event', function () { + [$order, $config] = fleetopsOrderCancelReasonOrder(); + + $order->cancelWithReason(' Customer moved house '); + + $event = collect(FleetOpsOrderCancelReasonRecorder::$events)->first(fn ($event) => $event instanceof OrderCanceled); + + expect($order->status)->toBe('canceled') + ->and($order->activities[0]->get('details'))->toBe('Customer moved house') + ->and($config->canceled->get('details'))->toBe('Order was canceled') + ->and($event)->not->toBeNull() + ->and($event->activity->get('details'))->toBe('Customer moved house'); +}); + +test('a plain cancel keeps the config activity and sends no reason', function () { + [$order, $config] = fleetopsOrderCancelReasonOrder(); + + $order->cancel(); + + $event = collect(FleetOpsOrderCancelReasonRecorder::$events)->first(fn ($event) => $event instanceof OrderCanceled); + + expect($order->activities[0])->toBe($config->canceled) + ->and($event->activity)->toBeNull(); +}); + +test('the canceled listener passes the reason on to the assigned driver', function () { + [$order] = fleetopsOrderCancelReasonOrder(); + $order->setRawAttributes(array_merge($order->getAttributes(), ['driver_assigned_uuid' => 'driver-uuid'])); + $order->setRelation('facilitator', null); + $order->setRelation('trackingNumber', null); + + $driver = new class extends Driver { + public array $notified = []; + + public function notify($instance) + { + $this->notified[] = $instance; + } + }; + + $listener = new class($driver) extends HandleOrderCanceled { + public function __construct(private Driver $driverForTest) + { + } + + protected function findAssignedDriver($order): ?Driver + { + return $this->driverForTest; + } + }; + + $event = new OrderCanceled($order); + $event->activity = new Activity(['code' => 'canceled', 'details' => 'Customer moved house']); + $listener->handle($event); + + expect($driver->notified)->toHaveCount(1) + ->and($driver->notified[0]->reason)->toBe('Customer moved house') + ->and($driver->notified[0]->data)->toBe(['id' => null, 'type' => 'order_canceled', 'reason' => 'Customer moved house']); +}); From 62415d4efdb7268061b727e109a265a9350e5ca2 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:43:06 +0800 Subject: [PATCH 5/5] test(orders): align request and assignment contracts with the cancel reason rule and observer-sent assignment --- server/tests/RequestContractsTest.php | 2 +- server/tests/Unit/Models/OrderPayloadRouteTest.php | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index 86f7280eb..045909958 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -507,7 +507,7 @@ protected function canUpdateDriver(): bool 'service_area' => 'service area', 'parent_fleet' => 'parent fleet', ]) - ->and(requestRules(CancelOrderRequest::class))->toBe(['order' => 'required|exists:orders,uuid']) + ->and(requestRules(CancelOrderRequest::class))->toBe(['order' => 'required|exists:orders,uuid', 'reason' => 'nullable|string|max:1000']) ->and(requestRules(DecodeTrackingNumberQR::class))->toBe(['code' => 'required|string']) ->and(requestRules(CreateServiceQuoteRequest::class))->toBe([]); }); diff --git a/server/tests/Unit/Models/OrderPayloadRouteTest.php b/server/tests/Unit/Models/OrderPayloadRouteTest.php index d4b65a949..759ee0bcc 100644 --- a/server/tests/Unit/Models/OrderPayloadRouteTest.php +++ b/server/tests/Unit/Models/OrderPayloadRouteTest.php @@ -205,10 +205,12 @@ function fleetopsOrderPayloadRouteOrder(array $attributes = []): FleetOpsOrderPa expect($assigned->assignDriver($driver))->toBe($assigned) ->and($assigned->notified)->toBe([]); - // Driver instance assignment notifies and saves + // Driver instance assignment saves; the order observer sends the assignment + // notification after the save, so assignDriver does not send it as well $order->assignDriver($driver); expect($order->driver_assigned_uuid)->toBe('driver-1') - ->and($order->notified)->toBe(['driver-assigned']) + ->and($order->notified)->toBe([]) + ->and($order->silentDriverAssignment)->toBeFalse() ->and($connection->table('orders')->value('driver_assigned_uuid'))->toBe('driver-1'); // Public id resolution recurses into instance assignment