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/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/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/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/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..fa64ba550 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -3,8 +3,10 @@ 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; use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; use Fleetbase\FleetOps\Flow\Activity; use Fleetbase\FleetOps\Http\Requests\CreateOrderRequest; @@ -24,6 +26,8 @@ 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; use Fleetbase\FleetOps\Support\Utils; @@ -32,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; @@ -353,6 +359,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 +403,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 +416,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 +428,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 +466,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 +479,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 +562,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 +573,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([ @@ -965,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 */ @@ -999,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 @@ -1279,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) { @@ -1292,11 +1404,156 @@ 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); } + /** + * 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/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/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index aee020d4b..3847787ef 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -596,6 +596,23 @@ 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 and the offer made to them is dropped + foreach ($orders as $order) { + $previousDriverUuid = $order->driver_assigned_uuid; + if (!$previousDriverUuid) { + continue; + } + + $order->driver_assigned_uuid = null; + $order->syncOfferWithAssignment(); + if ($order->isDirty('meta')) { + $order->saveQuietly(); + } + $order->syncOriginal(); + $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 169c815cf..d9cc3bd46 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']); @@ -602,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( [ @@ -772,11 +787,30 @@ 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); + } + + // 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 @@ -794,9 +828,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(); } /** @@ -833,9 +877,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/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/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/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 f2e306ae5..36e7ebf5e 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -8,13 +8,17 @@ 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; use Fleetbase\FleetOps\Support\Utils; 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; @@ -35,6 +39,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; @@ -276,6 +281,35 @@ 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). + */ + public bool $quietDriverUnassignment = false; + /** * Enforce a morph map. */ @@ -1429,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); @@ -1445,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(); } /** @@ -1461,6 +1523,200 @@ 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. + */ + 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. @@ -1718,7 +1974,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!'); @@ -1731,12 +1987,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/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/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/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/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/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/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..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. * @@ -58,7 +69,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 32ddd9bdc..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,10 @@ 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, ]); // 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/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/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..95599e330 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]; + } + + 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]; @@ -244,6 +276,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 +452,33 @@ 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; + + 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 { return $this->trackerFake; @@ -447,6 +543,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 +1182,141 @@ 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'); +}); + +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); + + $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/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..9b5e62f19 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,15 @@ 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 { $this->dispatchedForTest = true; @@ -911,10 +972,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 +1013,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 () { @@ -1057,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/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/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/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/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']); +}); 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 diff --git a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php new file mode 100644 index 000000000..f2d10b54b --- /dev/null +++ b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php @@ -0,0 +1,87 @@ +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.'); +}); + +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'); +}); + +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 new file mode 100644 index 000000000..d98aabb37 --- /dev/null +++ b/server/tests/Unit/Observers/OrderAssignmentObserverTest.php @@ -0,0 +1,122 @@ +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('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); + + expect($matches[0] ?? '')->not->toContain('notifyDriverAssigned()') + ->toContain('$this->silentDriverAssignment = (bool) $silent;'); +}); 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..d76e24fe3 --- /dev/null +++ b/server/tests/Unit/Support/OrderOperationsTest.php @@ -0,0 +1,467 @@ +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'); +}); + +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); + + 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']); +});