From ce91de23fb0f85e7593fb167ee02c8ab75b2848a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 21:45:43 +0800 Subject: [PATCH] fix(orders): remove driver offers; only adhoc orders are offered to drivers Assigning a driver started an expiring "offer" in meta.offer that the driver had to accept, and raised "driver not accepted" when they did not. That is not how dispatch works: an order assigned to a driver (by dispatch or auto-assign) is simply theirs. Only adhoc orders are offered, and the existing adhoc flow already does it: dispatching pings drivers within adhoc_distance (OrderPing), fleetops:dispatch-adhoc pings again every minute while nobody has taken it, and a driver takes it with `start` + `assign`. This reverts "feat(orders): driver offers with accept, decline and expiry": - meta.offer and its sync on assignment (observer, bulk assign) - POST v1/orders/{id}/accept and /decline - fleetops:expire-offers, the driver_not_accepted alert (DriverNotAccepted), OrderOfferDeclined, the order.offer_* broadcasts - `offer` on the order resources and fleetops.offer_timeout_minutes The cancel reason keeps its request helper, which it shared with decline. --- .../Console/Commands/ExpireOrderOffers.php | 158 ---------------- server/src/Events/OrderOfferChanged.php | 73 -------- .../Controllers/Api/v1/OrderController.php | 145 +-------------- .../Internal/v1/DriverController.php | 14 +- .../Internal/v1/OrderController.php | 12 +- server/src/Http/Resources/v1/Index/Order.php | 1 - server/src/Http/Resources/v1/Order.php | 2 - server/src/Models/Order.php | 168 ------------------ .../src/Notifications/DriverNotAccepted.php | 56 ------ .../src/Notifications/OrderOfferDeclined.php | 59 ------ server/src/Observers/OrderObserver.php | 11 -- .../src/Providers/FleetOpsServiceProvider.php | 4 - server/src/routes.php | 2 - .../tests/ApiOrderControllerContractsTest.php | 73 -------- .../Internal/OrderControllerHelpersTest.php | 6 +- .../OrderChangeNotificationsTest.php | 39 +--- .../Observers/OrderAssignmentObserverTest.php | 26 +-- .../Unit/Support/OrderOperationsTest.php | 80 +-------- 18 files changed, 16 insertions(+), 913 deletions(-) delete mode 100644 server/src/Console/Commands/ExpireOrderOffers.php delete mode 100644 server/src/Events/OrderOfferChanged.php delete mode 100644 server/src/Notifications/DriverNotAccepted.php delete mode 100644 server/src/Notifications/OrderOfferDeclined.php diff --git a/server/src/Console/Commands/ExpireOrderOffers.php b/server/src/Console/Commands/ExpireOrderOffers.php deleted file mode 100644 index 9c9374432..000000000 --- a/server/src/Console/Commands/ExpireOrderOffers.php +++ /dev/null @@ -1,158 +0,0 @@ -option('days')); - $perChunk = max(50, (int) $this->option('chunk')); - $dryRun = (bool) $this->option('dry'); - $lock = null; - - if (!$this->option('no-lock')) { - $lock = Cache::lock('fleetops:expire-offers', 300); - if (!$lock->get()) { - $this->warn('Another offer expiry run appears to be in progress.'); - - return self::SUCCESS; - } - } - - try { - $now = Carbon::now(); - $expired = 0; - - $this->ordersQuery($now->copy()->subDays($days))->orderBy('id')->chunkById($perChunk, function ($orders) use ($now, $dryRun, &$expired) { - foreach ($orders as $order) { - if ($this->expire($order, $now, $dryRun)) { - $expired++; - } - } - }); - - $this->info(($dryRun ? '[Dry Run] ' : '') . "Offers expired: {$expired}"); - - return self::SUCCESS; - } finally { - $lock?->release(); - } - } - - /** - * Orders with an open offer that are not finished or under way. - */ - protected function ordersQuery(Carbon $cutoff) - { - return Order::query() - ->withoutGlobalScopes() - ->where('meta->offer->status', Order::OFFER_OFFERED) - ->whereNotIn('status', ['completed', 'canceled', 'expired']) - ->where(fn ($query) => $query->whereNull('started')->orWhere('started', false)) - ->whereNull('deleted_at') - ->whereNotNull('company_uuid') - ->where('updated_at', '>=', $cutoff); - } - - /** - * Expire the order's offer when it has run out. Returns whether it was expired. - */ - public function expire(Order $order, Carbon $now, bool $dryRun = false): bool - { - $offer = $order->getOffer(); - $expiresAt = data_get($offer, 'expires_at'); - - if (data_get($offer, 'status') !== Order::OFFER_OFFERED || !$expiresAt || Carbon::parse($expiresAt)->isAfter($now)) { - return false; - } - - // An offer left behind on an order that no longer has a driver is not chased - if (!$order->driver_assigned_uuid) { - if (!$dryRun) { - $order->setMeta('offer', null); - $order->saveQuietly(); - } - - return false; - } - - if ($dryRun) { - return true; - } - - session(['company' => $order->company_uuid]); - - $order->expireOffer(); - $this->raiseAlert($order, (array) $offer); - $this->broadcast($order); - - return true; - } - - protected function raiseAlert(Order $order, array $offer): void - { - $metaKey = 'operational_alerts.' . self::ALERT_KEY; - $context = [ - 'driver' => $offer['driver'] ?? null, - 'offered_at' => $offer['offered_at'] ?? null, - 'expires_at' => $offer['expires_at'] ?? null, - ]; - - // once per offer: a later offer to another driver may alert again - if (data_get($order->getMeta($metaKey), 'context.offered_at') === $context['offered_at']) { - return; - } - - try { - NotificationRegistry::notify(DriverNotAccepted::class, $order, $context); - } catch (\Throwable $exception) { - Log::warning('Unable to raise driver_not_accepted alert', ['order' => $order->public_id, 'error' => $exception->getMessage()]); - } - - $order->setMeta($metaKey, [ - 'notified_at' => Carbon::now()->toDateTimeString(), - 'context' => $context, - ]); - $order->saveQuietly(); - $order->invalidateApiCache(); - } - - protected function broadcast(Order $order): void - { - try { - event(new OrderOfferChanged($order, Order::OFFER_EXPIRED)); - } catch (\Throwable $exception) { - Log::warning('Unable to broadcast order.offer_expired', ['order' => $order->public_id, 'error' => $exception->getMessage()]); - } - } -} diff --git a/server/src/Events/OrderOfferChanged.php b/server/src/Events/OrderOfferChanged.php deleted file mode 100644 index 08c82cd5f..000000000 --- a/server/src/Events/OrderOfferChanged.php +++ /dev/null @@ -1,73 +0,0 @@ -getMeta('offer', []); - - $this->eventId = uniqid('event_'); - $this->sentAt = Carbon::now()->toDateTimeString(); - $this->status = $status; - $this->channelNames = array_values(array_filter([ - 'order.' . $order->public_id, - 'order.' . $order->uuid, - $order->company_uuid ? 'company.' . $order->company_uuid : null, - ])); - $this->data = [ - 'id' => $order->public_id, - 'driver' => $offer['driver'] ?? null, - 'offer' => $offer ?: null, - ]; - } - - public function broadcastOn(): array - { - return array_map(fn (string $name) => new Channel($name), $this->channelNames); - } - - public function broadcastAs(): string - { - return 'order.offer_' . $this->status; - } - - public function broadcastWith(): array - { - return [ - 'id' => $this->eventId, - 'api_version' => config('api.version'), - 'event' => $this->broadcastAs(), - 'created_at' => $this->sentAt, - 'data' => $this->data, - ]; - } -} diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index fa64ba550..75e7cf96b 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -3,7 +3,6 @@ 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; @@ -26,7 +25,6 @@ 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; @@ -36,9 +34,7 @@ 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; @@ -1066,15 +1062,9 @@ 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; - // the driver took the order themselves, so their offer starts accepted + // if the order is adhoc and the parameter of `assign` is set with a valid driver id, assign the driver and continue if ($order->adhoc && $assignAdhocDriver && Str::startsWith($assignAdhocDriver, 'driver_')) { - $order->acceptOfferOnAssign = true; - try { - $order->assignDriver($assignAdhocDriver, true); - } finally { - $order->acceptOfferOnAssign = false; - } + $order->assignDriver($assignAdhocDriver, true); } /** @var \Fleetbase\Models\Driver */ @@ -1106,10 +1096,9 @@ public function startOrder(string $id, Request $request) return response()->apiError('Order has not been dispatched yet and cannot be started.'); } - // set order to started; starting the order answers an open offer + // set order to started $order->started = true; $order->started_at = now(); - $order->acceptOffer(false); $order->save(); // trigger start event @@ -1416,89 +1405,7 @@ public function cancelOrder(string $id, ?Request $request = null) } /** - * 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. + * The free-text `reason` sent with a cancel, trimmed to 1000 characters. */ protected function reasonFromRequest(Request $request): ?string { @@ -1510,50 +1417,6 @@ protected function reasonFromRequest(Request $request): ?string return Str::limit(trim($reason), 1000, ''); } - /** - * The driver answering an offer: the `driver` named in the request, or the - * driver profile of the authenticated user. - */ - protected function offerDriverFromRequest(Request $request): ?Driver - { - if ($request->filled('driver')) { - return $this->findDriverByPublicId($request->input('driver')); - } - - $user = $request->user(); - if (!$user instanceof User) { - $user = User::where('uuid', session('user'))->first(); - } - - if (!$user) { - return null; - } - - return Driver::where(['user_uuid' => $user->uuid, 'company_uuid' => $this->sessionCompany()])->first(); - } - - protected function broadcastOfferChange(Order $order, string $status): void - { - try { - event(new OrderOfferChanged($order, $status)); - } catch (\Throwable $exception) { - Log::warning('Unable to broadcast order offer change', ['order' => $order->public_id, 'error' => $exception->getMessage()]); - } - } - - protected function notifyOfferDeclined(Order $order, Driver $driver, ?string $reason): void - { - try { - NotificationRegistry::notify(OrderOfferDeclined::class, $order, [ - 'driver' => $driver->public_id, - 'driver_name' => $driver->name, - 'reason' => $reason, - ]); - } catch (\Throwable $exception) { - Log::warning('Unable to notify dispatch of declined order', ['order' => $order->public_id, 'error' => $exception->getMessage()]); - } - } - /** * Mark a specific place (waypoint, pickup, or drop-off) as the current * destination for an order payload. diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index 3847787ef..6e4dcc212 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -598,20 +598,12 @@ protected function clearDriverAssignmentsForOrders($orders): void Order::invalidateApiCacheManually(session('company')); // The bulk update bypasses the order observer, so the driver taken off each - // order is told here and the offer made to them is dropped + // order is told here foreach ($orders as $order) { $previousDriverUuid = $order->driver_assigned_uuid; - if (!$previousDriverUuid) { - continue; + if ($previousDriverUuid) { + $order->notifyDriverUnassigned($previousDriverUuid); } - - $order->driver_assigned_uuid = null; - $order->syncOfferWithAssignment(); - if ($order->isDirty('meta')) { - $order->saveQuietly(); - } - $order->syncOriginal(); - $order->notifyDriverUnassigned($previousDriverUuid); } } diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index d9cc3bd46..14cd0a4f5 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -802,15 +802,6 @@ protected function assignDriverToOrders($orderUuids, Driver $driver): void foreach ($replaced as $order) { $order->notifyDriverUnassigned($order->driver_assigned_uuid); } - - // The bulk update bypasses the model, so each order's driver offer is started here - $this->scopedToCompany(Order::whereIn('uuid', $orderUuids))->get()->each(function (Order $order) use ($driver) { - $order->setRelation('driverAssigned', $driver); - $order->syncOfferWithAssignment(); - if ($order->isDirty('meta')) { - $order->saveQuietly(); - } - }); } protected function dispatchBulkAssignedDriverNotification(array $orderUuids, Driver $driver): void @@ -877,10 +868,9 @@ public function start(Request $request) return response()->error('No driver assigned to order.'); } - // set order to started; starting the order answers an open offer + // set order to started $order->started = true; $order->started_at = now(); - $order->acceptOffer(false); $order->save(); // trigger start event diff --git a/server/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php index 39afca97a..1b413f22d 100644 --- a/server/src/Http/Resources/v1/Index/Order.php +++ b/server/src/Http/Resources/v1/Index/Order.php @@ -113,7 +113,6 @@ 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 43ac5dbed..6ed174612 100644 --- a/server/src/Http/Resources/v1/Order.php +++ b/server/src/Http/Resources/v1/Order.php @@ -99,7 +99,6 @@ 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, @@ -208,7 +207,6 @@ public function toWebhookPayload() 'status' => $this->status, 'adhoc' => $this->adhoc, 'meta' => data_get($this, 'meta', Utils::createObject()), - 'offer' => data_get($this, 'meta.offer'), 'dispatched_at' => $this->dispatched_at, 'started_at' => $this->started_at, 'scheduled_at' => $this->scheduled_at, diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 36e7ebf5e..0af1fae5a 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -18,7 +18,6 @@ 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; @@ -281,30 +280,11 @@ 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). */ @@ -1569,154 +1549,6 @@ public function editPolicy(): OrderEditPolicy return OrderEditPolicy::for($this); } - /** - * The driver offer on the order (`meta.offer`), or null when there is none. - */ - public function getOffer(): ?array - { - $offer = $this->getMeta('offer'); - - return is_array($offer) && !empty($offer) ? $offer : null; - } - - /** - * The status of the driver offer on the order, or null when there is none. - */ - public function offerStatus(): ?string - { - return data_get($this->getOffer(), 'status'); - } - - /** - * Minutes a driver has to accept an offer, from the company setting - * `fleetops.offer_timeout_minutes` (default 5). Zero disables expiry. - */ - public function offerTimeoutMinutes(): int - { - try { - $minutes = Setting::lookupForCompany($this->company_uuid ?? session('company'), 'fleetops.offer_timeout_minutes', static::DEFAULT_OFFER_TIMEOUT_MINUTES); - } catch (\Throwable) { - $minutes = static::DEFAULT_OFFER_TIMEOUT_MINUTES; - } - - return is_numeric($minutes) ? max(0, (int) $minutes) : static::DEFAULT_OFFER_TIMEOUT_MINUTES; - } - - /** - * Keep `meta.offer` in step with the driver assignment being saved: a newly - * assigned driver gets a fresh offer (accepted straight away when they took - * the order themselves), and removing the driver drops an open offer. - * Only sets attributes; the save in progress persists them. - */ - public function syncOfferWithAssignment(): self - { - $driverUuid = $this->driver_assigned_uuid; - - if (!$driverUuid) { - if (in_array($this->offerStatus(), [static::OFFER_OFFERED, static::OFFER_ACCEPTED], true)) { - $this->setMeta('offer', null); - } - - return $this; - } - - if ($this->exists && $this->editPolicy()->isTerminal()) { - return $this; - } - - $driverPublicId = data_get($this->relationLoaded('driverAssigned') ? $this->getRelation('driverAssigned') : null, 'public_id'); - if (!$driverPublicId || data_get($this->getRelation('driverAssigned'), 'uuid') !== $driverUuid) { - try { - $driverPublicId = Driver::where('uuid', $driverUuid)->withoutGlobalScopes()->value('public_id'); - } catch (\Throwable) { - $driverPublicId = null; - } - } - - $now = Carbon::now(); - $offer = [ - 'driver' => $driverPublicId, - 'offered_at' => $now->toIso8601ZuluString(), - 'expires_at' => null, - 'accepted_at' => null, - 'declined_at' => null, - 'status' => static::OFFER_OFFERED, - ]; - - if ($this->acceptOfferOnAssign) { - $offer['accepted_at'] = $offer['offered_at']; - $offer['status'] = static::OFFER_ACCEPTED; - } elseif ($minutes = $this->offerTimeoutMinutes()) { - $offer['expires_at'] = $now->copy()->addMinutes($minutes)->toIso8601ZuluString(); - } - - $this->setMeta('offer', $offer); - - return $this; - } - - /** - * Mark the open offer accepted. Returns false when there is no open offer. - */ - public function acceptOffer(bool $save = true): bool - { - $offer = $this->getOffer(); - if (!$offer || !in_array($offer['status'] ?? null, [static::OFFER_OFFERED, static::OFFER_EXPIRED], true)) { - return false; - } - - $offer['status'] = static::OFFER_ACCEPTED; - $offer['accepted_at'] = Carbon::now()->toIso8601ZuluString(); - $this->setMeta('offer', $offer); - - if ($save) { - $this->saveQuietly(); - $this->invalidateApiCache(); - } - - return true; - } - - /** - * Record that the driver declined the offer and take the order off them. - */ - public function declineOffer(?string $reason = null): self - { - $offer = $this->getOffer() ?? ['driver' => data_get($this, 'driverAssigned.public_id'), 'offered_at' => null, 'expires_at' => null, 'accepted_at' => null]; - - $offer['status'] = static::OFFER_DECLINED; - $offer['declined_at'] = Carbon::now()->toIso8601ZuluString(); - if ($reason) { - $offer['reason'] = $reason; - } - - $this->setMeta('offer', $offer); - $this->quietDriverUnassignment = true; - $this->driver_assigned_uuid = null; - $this->save(); - $this->quietDriverUnassignment = false; - - return $this; - } - - /** - * Mark an open offer expired. Returns false when there is no open offer. - */ - public function expireOffer(): bool - { - $offer = $this->getOffer(); - if (!$offer || ($offer['status'] ?? null) !== static::OFFER_OFFERED) { - return false; - } - - $offer['status'] = static::OFFER_EXPIRED; - $this->setMeta('offer', $offer); - $this->saveQuietly(); - $this->invalidateApiCache(); - - return true; - } - /** * Updates the activity of the order. * Updates the order status and inserts a new activity based on the provided activity details. diff --git a/server/src/Notifications/DriverNotAccepted.php b/server/src/Notifications/DriverNotAccepted.php deleted file mode 100644 index d106ec4bf..000000000 --- a/server/src/Notifications/DriverNotAccepted.php +++ /dev/null @@ -1,56 +0,0 @@ -order->tracking ?? $this->order->public_id; - - return (new MailMessage()) - ->subject("Driver has not accepted {$orderId}") - ->line($this->message()) - ->line('Please contact the driver or reassign the order.'); - } - - public function toArray($notifiable): array - { - return [ - 'event' => 'order.driver_not_accepted', - 'order_id' => $this->order->public_id, - 'order_uuid' => $this->order->uuid, - 'context' => $this->context, - 'message' => $this->message(), - ]; - } - - protected function message(): string - { - return sprintf('The driver has not accepted order %s within the offer window.', $this->order->tracking ?? $this->order->public_id); - } -} diff --git a/server/src/Notifications/OrderOfferDeclined.php b/server/src/Notifications/OrderOfferDeclined.php deleted file mode 100644 index 1fb14a80c..000000000 --- a/server/src/Notifications/OrderOfferDeclined.php +++ /dev/null @@ -1,59 +0,0 @@ -order->tracking ?? $this->order->public_id; - - return (new MailMessage()) - ->subject("Driver declined {$orderId}") - ->line($this->message()) - ->line('The order is unassigned and needs a driver.'); - } - - public function toArray($notifiable): array - { - return [ - 'event' => 'order.offer_declined', - 'order_id' => $this->order->public_id, - 'order_uuid' => $this->order->uuid, - 'context' => $this->context, - 'message' => $this->message(), - ]; - } - - protected function message(): string - { - $driver = $this->context['driver_name'] ?? 'The driver'; - $reason = $this->context['reason'] ?? null; - - return sprintf('%s declined order %s%s', $driver, $this->order->tracking ?? $this->order->public_id, $reason ? ': ' . $reason : '.'); - } -} diff --git a/server/src/Observers/OrderObserver.php b/server/src/Observers/OrderObserver.php index c2c4c21ed..45c9261bd 100644 --- a/server/src/Observers/OrderObserver.php +++ b/server/src/Observers/OrderObserver.php @@ -34,17 +34,6 @@ public function created(Order $order) $this->invalidateCache($order); } - /** - * Handle the Order "saving" event: a driver assignment being saved starts - * (or clears) the driver offer in the same write. - */ - public function saving(Order $order): void - { - if ($order->isDirty('driver_assigned_uuid')) { - $order->syncOfferWithAssignment(); - } - } - /** * Handle the Order "updating" event. * diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index cd90f69e5..bb24c8932 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -75,7 +75,6 @@ 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, @@ -151,7 +150,6 @@ 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(); @@ -254,8 +252,6 @@ public function registerNotifications() \Fleetbase\FleetOps\Notifications\LateDeparture::class, \Fleetbase\FleetOps\Notifications\RouteDeviation::class, \Fleetbase\FleetOps\Notifications\ProlongedStoppage::class, - \Fleetbase\FleetOps\Notifications\DriverNotAccepted::class, - \Fleetbase\FleetOps\Notifications\OrderOfferDeclined::class, \Fleetbase\FleetOps\Notifications\OrderChanged::class, \Fleetbase\FleetOps\Notifications\OrderUnassigned::class, ]); diff --git a/server/src/routes.php b/server/src/routes.php index 297f6caed..312b69ecf 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -212,8 +212,6 @@ function ($router) { $router->match(['post', 'patch'], '{id}/dispatch', 'OrderController@dispatchOrder'); $router->post('{id}/start', 'OrderController@startOrder'); $router->delete('{id}/cancel', 'OrderController@cancelOrder'); - $router->post('{id}/accept', 'OrderController@acceptOrder'); - $router->post('{id}/decline', 'OrderController@declineOrder'); $router->match(['post', 'patch'], '{id}/update-activity', 'OrderController@updateActivity'); $router->post('{id}/complete', 'OrderController@completeOrder'); $router->get('{id}/next-activity', 'OrderController@getNextActivity'); diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index 95599e330..5b87e94d8 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -72,20 +72,6 @@ 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]; @@ -462,23 +448,6 @@ public function cancelWithReason(?string $reason = null) 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; @@ -1278,45 +1247,3 @@ public function isString(string $key): bool 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/Feature/Http/Internal/OrderControllerHelpersTest.php b/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php index a94e37dba..1760fbc3f 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', 'meta'], + 'orders' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'tracking_number_uuid', 'driver_assigned_uuid', 'status'], 'contacts' => ['uuid', 'public_id', 'company_uuid', 'name', 'type'], 'vendors' => ['uuid', 'public_id', 'company_uuid', 'name'], 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'], @@ -168,9 +168,7 @@ public function __call($method, $arguments) (new FleetOpsInternalOrderHelpersProbe())->callProtected('assignDriverToOrders', ['order-1', 'order-2'], $driver); - // 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'); + expect($connection->table('orders')->where('driver_assigned_uuid', 'driver-1')->count())->toBe(2); }); test('dispatch domain event helper emits and returns the event', function () { diff --git a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php index f2d10b54b..ae9005236 100644 --- a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php +++ b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php @@ -1,18 +1,15 @@ '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); @@ -54,34 +50,3 @@ function fleetopsOrderChangeNotificationOrder(): Order ->and($notification->via(null))->toBe([FcmChannel::class, ApnChannel::class]) ->and($notification->toArray()['event'])->toBe('order.unassigned_notification'); }); - -test('offer alerts describe the declined or unanswered offer for dispatch', function () { - $order = fleetopsOrderChangeNotificationOrder(); - $declined = new OrderOfferDeclined($order, ['driver' => 'driver_one', 'driver_name' => 'Dee', 'reason' => 'Too far']); - $silent = new DriverNotAccepted($order, ['driver' => 'driver_one']); - - expect($declined->toArray(null))->toMatchArray([ - 'event' => 'order.offer_declined', - 'order_id' => 'order_change', - 'message' => 'Dee declined order FLB-CHANGE-1: Too far', - ]) - ->and($declined->via(null))->toBe(['mail', 'database']) - ->and($silent->toArray(null))->toMatchArray([ - 'event' => 'order.driver_not_accepted', - 'order_id' => 'order_change', - 'context' => ['driver' => 'driver_one'], - ]); -}); - -test('offer changes broadcast as order offer status on the order and company channels', function () { - $event = new OrderOfferChanged(fleetopsOrderChangeNotificationOrder(), Order::OFFER_EXPIRED); - - expect($event->broadcastAs())->toBe('order.offer_expired') - ->and(array_map(fn ($channel) => $channel->name, $event->broadcastOn()))->toBe(['order.order_change', 'order.order-uuid', 'company.company-1']) - ->and($event->broadcastWith())->toMatchArray(['event' => 'order.offer_expired']) - ->and($event->broadcastWith()['data'])->toBe([ - 'id' => 'order_change', - 'driver' => 'driver_one', - 'offer' => ['driver' => 'driver_one', 'status' => 'expired', 'expires_at' => '2026-10-13T12:05:00Z'], - ]); -}); diff --git a/server/tests/Unit/Observers/OrderAssignmentObserverTest.php b/server/tests/Unit/Observers/OrderAssignmentObserverTest.php index d98aabb37..5c7f289fd 100644 --- a/server/tests/Unit/Observers/OrderAssignmentObserverTest.php +++ b/server/tests/Unit/Observers/OrderAssignmentObserverTest.php @@ -9,8 +9,8 @@ /** * Covers how the order observer follows driver assignment: a replaced driver is - * told they were unassigned, the new driver is notified exactly once (and not at - * all for a silent assignment), and saving an assignment starts the driver offer. + * told they were unassigned, and the new driver is notified exactly once (and not + * at all for a silent assignment). */ class FleetOpsOrderAssignmentObserverOrder extends Order { @@ -49,13 +49,6 @@ public function notifyDriverUnassigned($driver): bool return true; } - - public function syncOfferWithAssignment(): Order - { - $this->events[] = 'offer'; - - return $this; - } } beforeEach(function () { @@ -98,21 +91,6 @@ public function orderChanged(Order $order): bool ->and($silent->events)->toBe([]); }); -test('saving a driver assignment starts the offer in the same write', function () { - $order = new FleetOpsOrderAssignmentObserverOrder(); - $order->driverDirty = true; - - (new OrderObserver())->saving($order); - - $unchanged = new FleetOpsOrderAssignmentObserverOrder(); - $unchanged->driverDirty = false; - - (new OrderObserver())->saving($unchanged); - - expect($order->events)->toBe(['offer']) - ->and($unchanged->events)->toBe([]); -}); - test('assign driver relies on the observer instead of notifying before the save', function () { $source = file_get_contents(dirname(__DIR__, 3) . '/src/Models/Order.php'); preg_match('/public function assignDriver\(.*?\n }\n/s', $source, $matches); diff --git a/server/tests/Unit/Support/OrderOperationsTest.php b/server/tests/Unit/Support/OrderOperationsTest.php index d76e24fe3..5ac197271 100644 --- a/server/tests/Unit/Support/OrderOperationsTest.php +++ b/server/tests/Unit/Support/OrderOperationsTest.php @@ -15,9 +15,7 @@ function event(...$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; @@ -26,12 +24,11 @@ function event(...$arguments) 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. + * driver's stop when an in-progress route changes, and the order.changed event an + * edit commits. */ class FleetOpsOrderOpsEventRecorder { @@ -378,79 +375,6 @@ function fleetopsOrderOpsOrder(SQLiteConnection $connection, array $order = [], ->and(OrderEdit::entitiesMode(null))->toBe('append'); }); -test('assigning a driver starts an offer that is accepted declined or expired', function () { - $connection = fleetopsOrderOpsBoot(); - Carbon::setTestNow('2026-10-13 12:00:00'); - $order = fleetopsOrderOpsOrder($connection); - - $order->driver_assigned_uuid = 'driver-1'; - $order->syncOfferWithAssignment(); - - expect($order->getOffer())->toBe([ - 'driver' => 'driver_one', - 'offered_at' => '2026-10-13T12:00:00Z', - 'expires_at' => '2026-10-13T12:05:00Z', - 'accepted_at' => null, - 'declined_at' => null, - 'status' => 'offered', - ]); - $order->saveQuietly(); - - // adhoc drivers who took the order themselves start accepted - $adhoc = new Order(['driver_assigned_uuid' => 'driver-1']); - $adhoc->acceptOfferOnAssign = true; - $adhoc->syncOfferWithAssignment(); - expect($adhoc->offerStatus())->toBe('accepted') - ->and($adhoc->getOffer()['accepted_at'])->toBe('2026-10-13T12:00:00Z'); - - // the expiry command leaves an offer alone until it runs out - $command = new ExpireOrderOffers(); - expect($command->expire($order, Carbon::parse('2026-10-13 12:04:00')))->toBeFalse(); - - expect($command->expire($order, Carbon::parse('2026-10-13 12:06:00')))->toBeTrue(); - $stored = Order::where('uuid', 'order-1')->first(); - expect($stored->offerStatus())->toBe('expired') - ->and($stored->getMeta('operational_alerts.driver_not_accepted.context'))->toBe([ - 'driver' => 'driver_one', - 'offered_at' => '2026-10-13T12:00:00Z', - 'expires_at' => '2026-10-13T12:05:00Z', - ]) - ->and(fleetopsOrderOpsEvents()->of(OrderOfferChanged::class)[0]->broadcastAs())->toBe('order.offer_expired') - ->and(fleetopsOrderOpsEvents()->of(OrderOfferChanged::class)[0]->channelNames)->toBe(['order.order_one', 'order.order-1', 'company.company-1']) - ->and($command->expire($stored, Carbon::parse('2026-10-13 12:07:00')))->toBeFalse(); - - // a late acceptance still counts - expect($stored->acceptOffer())->toBeTrue() - ->and(Order::where('uuid', 'order-1')->first()->offerStatus())->toBe('accepted'); - - // declining records it and takes the order off the driver - $stored->setMeta('offer', array_merge($stored->getOffer(), ['status' => 'offered'])); - $stored->declineOffer('Too far'); - $declined = Order::where('uuid', 'order-1')->first(); - expect($declined->driver_assigned_uuid)->toBeNull() - ->and($declined->offerStatus())->toBe('declined') - ->and($declined->getOffer()['reason'])->toBe('Too far'); - - // removing the driver drops an open offer but keeps a declined one - $declined->syncOfferWithAssignment(); - expect($declined->offerStatus())->toBe('declined'); - $open = new Order(); - $open->setMeta('offer', ['status' => 'offered']); - $open->syncOfferWithAssignment(); - expect($open->getOffer())->toBeNull(); - - Carbon::setTestNow(); -}); - -test('the expiry command drops offers left on orders without a driver', function () { - $connection = fleetopsOrderOpsBoot(); - $order = fleetopsOrderOpsOrder($connection, ['meta' => json_encode(['offer' => ['driver' => 'driver_one', 'status' => 'offered', 'expires_at' => '2026-10-13T12:05:00Z']])]); - - expect((new ExpireOrderOffers())->expire($order, Carbon::parse('2026-10-13 12:06:00')))->toBeFalse() - ->and(Order::where('uuid', 'order-1')->first()->getOffer())->toBeNull() - ->and(fleetopsOrderOpsEvents()->of(OrderOfferChanged::class))->toBe([]); -}); - test('a driver taken off an order is told and loses it as their current job', function () { $connection = fleetopsOrderOpsBoot(); $order = fleetopsOrderOpsOrder($connection);