diff --git a/app/Actions/Shipments/CreateShipment.php b/app/Actions/Shipments/CreateShipment.php index 6d2e58b..d2a6bd7 100644 --- a/app/Actions/Shipments/CreateShipment.php +++ b/app/Actions/Shipments/CreateShipment.php @@ -71,7 +71,10 @@ public function handle( DB::commit(); - event(new ShipmentCarrierUpdated($shipment)); + // Only fire carrier updated event if a carrier was actually assigned + if ($carrierId !== null) { + event(new ShipmentCarrierUpdated($shipment)); + } return $shipment; } diff --git a/app/Listeners/Shipments/UpdateShipmentState.php b/app/Listeners/Shipments/UpdateShipmentState.php index eede55b..59dedeb 100644 --- a/app/Listeners/Shipments/UpdateShipmentState.php +++ b/app/Listeners/Shipments/UpdateShipmentState.php @@ -56,7 +56,10 @@ public function handle(ShipmentCarrierUpdated|ShipmentStopsUpdated|ShipmentCarri protected function handleCarrierChanged(ShipmentCarrierUpdated $event): void { - if ($event->shipment->state::class === Pending::class) { + if ( + $event->shipment->state::class === Pending::class && + $event->shipment->carrier_id !== null + ) { $event->shipment->state->transitionTo(Booked::class); } } diff --git a/docs/ShipmentStates.md b/docs/ShipmentStates.md new file mode 100644 index 0000000..e01d72f --- /dev/null +++ b/docs/ShipmentStates.md @@ -0,0 +1,174 @@ +# Shipment State Machine - Complete Transition Guide + +## All State Transitions + +### 1. PENDING → BOOKED + +| Trigger | Source | Conditions | Notes | +|---------|---------|------------|-------| +| **Automatic** | `CreateShipment` action | • Shipment created with carrier_id ≠ null | Event: `ShipmentCarrierUpdated` fired → `UpdateShipmentState::handleCarrierChanged()` | +| **Automatic** | `UpdateShipmentCarrierDetails` action | • Carrier assigned to pending shipment
• carrier_id ≠ null | Event: `ShipmentCarrierUpdated` fired → `UpdateShipmentState::handleCarrierChanged()` | + +#### Important Note: +- Shipments created **without** a carrier remain in **Pending** state +- The transition only occurs when `carrier_id` is not null + +### 2. BOOKED → DISPATCHED + +| Trigger | Source | Conditions | Notes | +|---------|---------|------------|-------| +| **Manual** | `DispatchShipment` action | • User clicks dispatch button | Direct transition via `$shipment->state->transitionTo(Dispatched::class)` | + +### 3. DISPATCHED → AT PICKUP + +| Trigger | Source | Conditions | Notes | +|---------|---------|------------|-------| +| **Automatic** | ShipmentStop model update | • Stop with type = Pickup
• Stop.arrived_at timestamp set
• Stop is current stop | Event: `ShipmentStopsUpdated` fired → `UpdateShipmentState::handleStopsChanged()` → `ShipmentStateService::calculateStopState()` | + +### 4. AT PICKUP → IN TRANSIT + +| Trigger | Source | Conditions | Notes | +|---------|---------|------------|-------| +| **Automatic** | ShipmentStop model update | • Pickup stop completed
• Moving to next stop
• Not at first stop anymore | Event: `ShipmentStopsUpdated` fired → `UpdateShipmentState::handleStopsChanged()` → `ShipmentStateService::calculateStopState()` | + +### 5. IN TRANSIT → AT DELIVERY + +| Trigger | Source | Conditions | Notes | +|---------|---------|------------|-------| +| **Automatic** | ShipmentStop model update | • Stop with type = Delivery
• Stop.arrived_at timestamp set
• Stop is current stop | Event: `ShipmentStopsUpdated` fired → `UpdateShipmentState::handleStopsChanged()` → `ShipmentStateService::calculateStopState()` | + +### 6. AT DELIVERY → DELIVERED + +| Trigger | Source | Conditions | Notes | +|---------|---------|------------|-------| +| **Automatic** | ShipmentStop model update | • Last stop has loaded_unloaded_at timestamp
• All deliveries completed | Event: `ShipmentStopsUpdated` fired → `UpdateShipmentState::handleStopsChanged()` → `ShipmentStateService::calculateStopState()` | + +### 7. CARRIER BOUNCE TRANSITIONS (→ PENDING) + +| From State | Trigger | Source | Conditions | Notes | +|------------|---------|---------|------------|-------| +| **Booked** | `BounceCarrier` action | User bounces carrier | • Shipment in Booked state | Event: `ShipmentCarrierBounced` fired → `UpdateShipmentState::handleCarrierBounced()` | +| **Dispatched** | `BounceCarrier` action | User bounces carrier | • Shipment in Dispatched state | Event: `ShipmentCarrierBounced` fired → `UpdateShipmentState::handleCarrierBounced()` | +| **AtPickup** | `BounceCarrier` action | User bounces carrier | • Shipment in AtPickup state | Event: `ShipmentCarrierBounced` fired → `UpdateShipmentState::handleCarrierBounced()` | + +### 8. CANCEL TRANSITIONS (ANY STATE → CANCELED) + +| From State | Trigger | Source | Conditions | Notes | +|------------|---------|---------|------------|-------| +| **Any State** | `CancelShipment` action | User cancels shipment | • User has permission | Direct transition via `$shipment->state->transitionTo(Canceled::class)` | + +### 9. UNCANCEL TRANSITIONS (CANCELED → VARIOUS) + +| To State | Trigger | Source | Conditions | Notes | +|----------|---------|---------|------------|-------| +| **Pending** | `UncancelShipment` action | User uncancels | • No carrier assigned | Direct transition | +| **Booked** | `UncancelShipment` action | User uncancels | • Has carrier
• No stop progress | Direct transition | +| **Booked → Dispatched → [Calculated]** | `UncancelShipment` action | User uncancels | • Has carrier
• Has stop progress (arrived_at on any stop) | 1. Transition to Booked
2. Transition to Dispatched
3. Calculate final state based on stops
4. Transition through intermediate states | + +## Event Sources and Triggers + +### Model Events (Automatic) +- **ShipmentStop** model (boot method): + - `created` → fires `ShipmentStopsUpdated` + - `updated` → fires `ShipmentStopsUpdated` + - `deleted` → fires `ShipmentStopsUpdated` + +### Action Events +- **CreateShipment** → fires `ShipmentCarrierUpdated` +- **UpdateShipmentCarrierDetails** → fires `ShipmentCarrierUpdated` +- **BounceCarrier** → fires `ShipmentCarrierBounced` + +### Direct State Transitions (No Events) +- **DispatchShipment** → Direct transition to Dispatched +- **CancelShipment** → Direct transition to Canceled +- **UncancelShipment** → Direct transitions based on conditions + +## State Calculation Logic + +The `ShipmentStateService::calculateStopState()` determines state from stop data: + +```php +// Pseudocode of the logic: +IF no stops exist: + return Dispatched + +IF last stop has loaded_unloaded_at: + return Delivered + +IF current stop exists: + IF current stop type = Pickup: + return AtPickup + IF current stop type = Delivery: + return AtDelivery + +IF next stop is NOT the first stop: + return InTransit + +return Dispatched +``` + +## Key Implementation Details + +1. **Event Listeners**: `UpdateShipmentState` listener handles 4 events: + - `ShipmentCarrierUpdated` + - `ShipmentStopsUpdated` + - `ShipmentCarrierBounced` + - `ShipmentStateChanged` + +2. **State Transition Rules**: + - Forward transitions emit events for each intermediate state + - Backward transitions update directly without events + - Only allowed transitions defined in `ShipmentState::config()` can occur + +3. **Stop Progress Detection**: + - `arrived_at` - Driver arrived at stop + - `loaded_unloaded_at` - Stop completed + - `left_at` - Driver left stop + - Current stop determined by these timestamps + +4. **Files Involved**: + - States: `/app/States/Shipments/*.php` + - Listener: `/app/Listeners/Shipments/UpdateShipmentState.php:40` + - Service: `/app/Services/Shipments/ShipmentStateService.php:20` + - Model: `/app/Models/Shipments/ShipmentStop.php:58-76` + - Actions: Various in `/app/Actions/` + +## Visual State Diagram + +```mermaid +stateDiagram-v2 + [*] --> Pending: Initial + + Pending --> Booked: Auto: carrier assigned + Pending --> Canceled: Manual: cancel + + Booked --> Dispatched: Manual: dispatch action + Booked --> Pending: Auto: carrier bounced + Booked --> Canceled: Manual: cancel + + Dispatched --> AtPickup: Auto: arrived at pickup + Dispatched --> Pending: Auto: carrier bounced + Dispatched --> Canceled: Manual: cancel + + AtPickup --> InTransit: Auto: pickup completed + AtPickup --> Pending: Auto: carrier bounced + AtPickup --> Canceled: Manual: cancel + + InTransit --> AtDelivery: Auto: arrived at delivery + InTransit --> Canceled: Manual: cancel + + AtDelivery --> Delivered: Auto: delivery completed + AtDelivery --> Canceled: Manual: cancel + + Delivered --> Canceled: Manual: cancel + Delivered --> [*]: Final + + Canceled --> Pending: Manual: uncancel (no carrier) + Canceled --> Booked: Manual: uncancel (has carrier) + + note right of Canceled + Uncancel with stops: + Canceled → Booked → Dispatched + → [State based on stops] + end note +``` \ No newline at end of file diff --git a/tests/Unit/Actions/Shipments/UncancelShipmentTest.php b/tests/Unit/Actions/Shipments/UncancelShipmentTest.php index 122ed4c..4778a53 100644 --- a/tests/Unit/Actions/Shipments/UncancelShipmentTest.php +++ b/tests/Unit/Actions/Shipments/UncancelShipmentTest.php @@ -1,7 +1,5 @@ setupOrganization(); - } - - /** @test */ - public function it_can_uncancel_a_shipment_with_no_carrier_to_pending_state() - { - // Create a shipment in Canceled state with no carrier - $shipment = Shipment::factory()->create([ - 'organization_id' => $this->organization->id, - 'carrier_id' => null, - ]); - $shipment->state->transitionTo(Canceled::class); - $this->assertEquals(Canceled::class, get_class($shipment->state)); - - // Execute the uncancel action - $action = new UncancelShipment(); - $result = $action->handle($shipment); - - // Assert the shipment state has changed to Pending - // (No carrier shipments stay in Pending - no listener recalculation needed) - $this->assertEquals(Pending::class, get_class($result->state)); - $this->assertEquals('pending', $result->state->getValue()); - } - - /** @test */ - public function it_can_uncancel_a_shipment_with_carrier_but_no_stop_progress_to_booked_state() - { - // Create a carrier - $carrier = Carrier::factory()->create([ - 'organization_id' => $this->organization->id, - ]); - - // Create a shipment in Canceled state with a carrier but no stop progress - $shipment = Shipment::factory()->create([ - 'organization_id' => $this->organization->id, - 'carrier_id' => $carrier->id, - ]); - - // Add stops with no progress - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 1, - 'stop_type' => StopType::Pickup, - 'arrived_at' => null, - 'loaded_unloaded_at' => null, - 'left_at' => null, - ]); - - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 2, - 'stop_type' => StopType::Delivery, - 'arrived_at' => null, - 'loaded_unloaded_at' => null, - 'left_at' => null, - ]); - - $shipment->state->transitionTo(Canceled::class); - $this->assertEquals(Canceled::class, get_class($shipment->state)); - - // Execute the uncancel action - $action = new UncancelShipment(); - $result = $action->handle($shipment); - - // Assert the shipment state has changed to Booked - // (Action transitions to Booked since no stop progress, doesn't auto-dispatch) - $this->assertEquals(Booked::class, get_class($result->state)); - $this->assertEquals('booked', $result->state->getValue()); - } - - /** @test */ - public function it_can_uncancel_a_shipment_which_listener_recalculates_to_at_pickup() - { - // Create a carrier - $carrier = Carrier::factory()->create([ - 'organization_id' => $this->organization->id, - ]); - - // Create a shipment in Canceled state with a carrier and stop progress - $shipment = Shipment::factory()->create([ - 'organization_id' => $this->organization->id, - 'carrier_id' => $carrier->id, - ]); - - // Add stops with pickup progress - currently at pickup (arrived but not left) - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 1, - 'stop_type' => StopType::Pickup, - 'arrived_at' => now(), - 'loaded_unloaded_at' => null, - 'left_at' => null, - ]); - - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 2, - 'stop_type' => StopType::Delivery, - 'arrived_at' => null, - 'loaded_unloaded_at' => null, - 'left_at' => null, - ]); - - $shipment->state->transitionTo(Canceled::class); - $this->assertEquals(Canceled::class, get_class($shipment->state)); - - // Execute the uncancel action - $action = new UncancelShipment(); - $result = $action->handle($shipment); - - // Assert the shipment state has changed to AtPickup - // (Action transitions to Booked, then state service recalculates to AtPickup based on stop progress) - $this->assertEquals(AtPickup::class, get_class($result->state)); - $this->assertEquals('at_pickup', $result->state->getValue()); - } - - /** @test */ - public function it_can_uncancel_a_shipment_which_listener_recalculates_to_in_transit() - { - // Create a carrier - $carrier = Carrier::factory()->create([ - 'organization_id' => $this->organization->id, - ]); - - // Create a shipment in Canceled state with a carrier and stop progress - $shipment = Shipment::factory()->create([ - 'organization_id' => $this->organization->id, - 'carrier_id' => $carrier->id, - ]); - - // Add stops with in-transit progress - pickup completed (arrived, loaded, and left) - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 1, - 'stop_type' => StopType::Pickup, - 'arrived_at' => now()->subHours(2), - 'loaded_unloaded_at' => now()->subHours(1.5), - 'left_at' => now()->subHours(1), - ]); - - // Delivery not yet reached - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 2, - 'stop_type' => StopType::Delivery, - 'arrived_at' => null, - 'loaded_unloaded_at' => null, - 'left_at' => null, - ]); - - $shipment->state->transitionTo(Canceled::class); - $this->assertEquals(Canceled::class, get_class($shipment->state)); - - // Execute the uncancel action - $action = new UncancelShipment(); - $result = $action->handle($shipment); - - // Assert the shipment state has changed to InTransit - // (Action transitions to Booked, then state service recalculates to InTransit based on stop progress) - $this->assertEquals(InTransit::class, get_class($result->state)); - $this->assertEquals('in_transit', $result->state->getValue()); - } - - /** @test */ - public function it_can_uncancel_a_shipment_which_listener_recalculates_to_at_delivery() - { - // Create a carrier - $carrier = Carrier::factory()->create([ - 'organization_id' => $this->organization->id, - ]); - - // Create a shipment in Canceled state with a carrier and stop progress - $shipment = Shipment::factory()->create([ - 'organization_id' => $this->organization->id, - 'carrier_id' => $carrier->id, - ]); - - // Add stops with at-delivery progress - pickup completed - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 1, - 'stop_type' => StopType::Pickup, - 'arrived_at' => now()->subHours(3), - 'loaded_unloaded_at' => now()->subHours(2.5), - 'left_at' => now()->subHours(2), - ]); - - // Currently at delivery (arrived but not left) - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 2, - 'stop_type' => StopType::Delivery, - 'arrived_at' => now()->subHour(), - 'loaded_unloaded_at' => null, - 'left_at' => null, - ]); - - $shipment->state->transitionTo(Canceled::class); - $this->assertEquals(Canceled::class, get_class($shipment->state)); - - // Execute the uncancel action - $action = new UncancelShipment(); - $result = $action->handle($shipment); - - // Assert the shipment state has changed to AtDelivery - // (Action transitions to Booked, then state service recalculates to AtDelivery based on stop progress) - $this->assertEquals(AtDelivery::class, get_class($result->state)); - $this->assertEquals('at_delivery', $result->state->getValue()); - } - - /** @test */ - public function it_can_uncancel_a_shipment_which_listener_recalculates_to_delivered() - { - // Create a carrier - $carrier = Carrier::factory()->create([ - 'organization_id' => $this->organization->id, - ]); - - // Create a shipment in Canceled state with a carrier and completed delivery - $shipment = Shipment::factory()->create([ - 'organization_id' => $this->organization->id, - 'carrier_id' => $carrier->id, - ]); - - // Add stops with completed delivery - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 1, - 'stop_type' => StopType::Pickup, - 'arrived_at' => now()->subHours(4), - 'loaded_unloaded_at' => now()->subHours(3), - 'left_at' => now()->subHours(2.5), - ]); - - ShipmentStop::factory()->create([ - 'shipment_id' => $shipment->id, - 'stop_number' => 2, - 'stop_type' => StopType::Delivery, - 'arrived_at' => now()->subHours(2), - 'loaded_unloaded_at' => now()->subHour(), - 'left_at' => now()->subMinutes(30), - ]); - - $shipment->state->transitionTo(Canceled::class); - $this->assertEquals(Canceled::class, get_class($shipment->state)); - - // Execute the uncancel action - $action = new UncancelShipment(); - $result = $action->handle($shipment); - - // Assert the shipment state has changed to Delivered - // (Action transitions to Booked, then state service recalculates to Delivered based on stop progress) - $this->assertEquals(Delivered::class, get_class($result->state)); - $this->assertEquals('delivered', $result->state->getValue()); - } - - /** @test */ - public function it_can_uncancel_a_shipment_that_was_only_booked_not_dispatched() - { - // Create a carrier - $carrier = Carrier::factory()->create([ - 'organization_id' => $this->organization->id, - ]); - - // Create a shipment in Canceled state with a carrier but NO stops at all - // This represents a shipment that was only Booked (carrier assigned) but never Dispatched - $shipment = Shipment::factory()->create([ - 'organization_id' => $this->organization->id, - 'carrier_id' => $carrier->id, - ]); - - // First transition to Booked to simulate the real workflow - $shipment->state->transitionTo(Booked::class); - // Then cancel it - $shipment->state->transitionTo(Canceled::class); - $this->assertEquals(Canceled::class, get_class($shipment->state)); - - // Execute the uncancel action - $action = new UncancelShipment(); - $result = $action->handle($shipment); - - // Assert the shipment state has changed to Booked - // (Action transitions to Booked since no stop progress, doesn't auto-dispatch) - $this->assertEquals(Booked::class, get_class($result->state)); - $this->assertEquals('booked', $result->state->getValue()); - } - - /** @test */ - public function it_requires_shipment_edit_permission() - { - // Create a user without shipment edit permission - $user = User::factory()->create(); - $this->organization->users()->attach($user); - $role = Role::create(['name' => 'tester', 'organization_id' => $this->organization->id]); - $user->assignRole($role); - - // Create a mock request with the unauthorized user - $request = Mockery::mock(ActionRequest::class); - $request->shouldReceive('user')->andReturn($user); - - // Create the action and check authorization - $action = new UncancelShipment(); - $this->assertFalse($action->authorize($request)); - - // Now give the user permission and verify it works - $role->givePermissionTo(Permission::SHIPMENT_EDIT); - $this->assertTrue($action->authorize($request)); - } - - /** @test */ - public function it_returns_redirect_response_when_called_as_controller() - { - // Create a shipment in Canceled state - $shipment = Shipment::factory()->create([ - 'organization_id' => $this->organization->id, - ]); - $shipment->state->transitionTo(Canceled::class); - - // Create a user with shipment edit permission - $user = User::factory()->create(); - $this->organization->users()->attach($user); - $role = Role::create(['name' => 'editor', 'organization_id' => $this->organization->id]); - $role->givePermissionTo(Permission::SHIPMENT_EDIT); - $user->assignRole($role); - - // Create a mock request with the authorized user - $request = Mockery::mock(ActionRequest::class); - $request->shouldReceive('user')->andReturn($user); - - // Execute the action as controller - $action = new UncancelShipment(); - $response = $action->asController($request, $shipment); - - // Assert the response is a redirect with success message - $this->assertStringContainsString('Redirecting', $response->getContent()); - $this->assertTrue($response->isRedirect()); - } -} +uses(RefreshDatabase::class, WithOrganization::class); + +beforeEach(function () { + $this->setupOrganization(); +}); + +afterEach(function () { + \Mockery::close(); +}); + +it('can uncancel a shipment with no carrier to pending state', function () { + // Create a shipment in Canceled state with no carrier + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => null, + ]); + $shipment->state->transitionTo(Canceled::class); + expect(get_class($shipment->state))->toBe(Canceled::class); + + // Execute the uncancel action + $action = new UncancelShipment(); + $result = $action->handle($shipment); + + // Assert the shipment state has changed to Pending + // (No carrier shipments stay in Pending - no listener recalculation needed) + expect(get_class($result->state))->toBe(Pending::class); + expect($result->state->getValue())->toBe('pending'); +}); + +it('can uncancel a shipment with carrier but no stop progress to booked state', function () { + // Create a carrier + $carrier = Carrier::factory()->create([ + 'organization_id' => $this->organization->id, + ]); + + // Create a shipment in Canceled state with a carrier but no stop progress + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + ]); + + // Add stops with no progress + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 1, + 'stop_type' => StopType::Pickup, + 'arrived_at' => null, + 'loaded_unloaded_at' => null, + 'left_at' => null, + ]); + + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 2, + 'stop_type' => StopType::Delivery, + 'arrived_at' => null, + 'loaded_unloaded_at' => null, + 'left_at' => null, + ]); + + $shipment->state->transitionTo(Canceled::class); + expect(get_class($shipment->state))->toBe(Canceled::class); + + // Execute the uncancel action + $action = new UncancelShipment(); + $result = $action->handle($shipment); + + // Assert the shipment state has changed to Booked + // (Action transitions to Booked since no stop progress, doesn't auto-dispatch) + expect(get_class($result->state))->toBe(Booked::class); + expect($result->state->getValue())->toBe('booked'); +}); + +it('can uncancel a shipment which listener recalculates to at pickup', function () { + // Create a carrier + $carrier = Carrier::factory()->create([ + 'organization_id' => $this->organization->id, + ]); + + // Create a shipment in Canceled state with a carrier and stop progress + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + ]); + + // Add stops with pickup progress - currently at pickup (arrived but not left) + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 1, + 'stop_type' => StopType::Pickup, + 'arrived_at' => now(), + 'loaded_unloaded_at' => null, + 'left_at' => null, + ]); + + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 2, + 'stop_type' => StopType::Delivery, + 'arrived_at' => null, + 'loaded_unloaded_at' => null, + 'left_at' => null, + ]); + + $shipment->state->transitionTo(Canceled::class); + expect(get_class($shipment->state))->toBe(Canceled::class); + + // Execute the uncancel action + $action = new UncancelShipment(); + $result = $action->handle($shipment); + + // Assert the shipment state has changed to AtPickup + // (Action transitions to Booked, then state service recalculates to AtPickup based on stop progress) + expect(get_class($result->state))->toBe(AtPickup::class); + expect($result->state->getValue())->toBe('at_pickup'); +}); + +it('can uncancel a shipment which listener recalculates to in transit', function () { + // Create a carrier + $carrier = Carrier::factory()->create([ + 'organization_id' => $this->organization->id, + ]); + + // Create a shipment in Canceled state with a carrier and stop progress + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + ]); + + // Add stops with in-transit progress - pickup completed (arrived, loaded, and left) + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 1, + 'stop_type' => StopType::Pickup, + 'arrived_at' => now()->subHours(2), + 'loaded_unloaded_at' => now()->subHours(1.5), + 'left_at' => now()->subHours(1), + ]); + + // Delivery not yet reached + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 2, + 'stop_type' => StopType::Delivery, + 'arrived_at' => null, + 'loaded_unloaded_at' => null, + 'left_at' => null, + ]); + + $shipment->state->transitionTo(Canceled::class); + expect(get_class($shipment->state))->toBe(Canceled::class); + + // Execute the uncancel action + $action = new UncancelShipment(); + $result = $action->handle($shipment); + + // Assert the shipment state has changed to InTransit + // (Action transitions to Booked, then state service recalculates to InTransit based on stop progress) + expect(get_class($result->state))->toBe(InTransit::class); + expect($result->state->getValue())->toBe('in_transit'); +}); + +it('can uncancel a shipment which listener recalculates to at delivery', function () { + // Create a carrier + $carrier = Carrier::factory()->create([ + 'organization_id' => $this->organization->id, + ]); + + // Create a shipment in Canceled state with a carrier and stop progress + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + ]); + + // Add stops with at-delivery progress - pickup completed + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 1, + 'stop_type' => StopType::Pickup, + 'arrived_at' => now()->subHours(3), + 'loaded_unloaded_at' => now()->subHours(2.5), + 'left_at' => now()->subHours(2), + ]); + + // Currently at delivery (arrived but not left) + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 2, + 'stop_type' => StopType::Delivery, + 'arrived_at' => now()->subHour(), + 'loaded_unloaded_at' => null, + 'left_at' => null, + ]); + + $shipment->state->transitionTo(Canceled::class); + expect(get_class($shipment->state))->toBe(Canceled::class); + + // Execute the uncancel action + $action = new UncancelShipment(); + $result = $action->handle($shipment); + + // Assert the shipment state has changed to AtDelivery + // (Action transitions to Booked, then state service recalculates to AtDelivery based on stop progress) + expect(get_class($result->state))->toBe(AtDelivery::class); + expect($result->state->getValue())->toBe('at_delivery'); +}); + +it('can uncancel a shipment which listener recalculates to delivered', function () { + // Create a carrier + $carrier = Carrier::factory()->create([ + 'organization_id' => $this->organization->id, + ]); + + // Create a shipment in Canceled state with a carrier and completed delivery + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + ]); + + // Add stops with completed delivery + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 1, + 'stop_type' => StopType::Pickup, + 'arrived_at' => now()->subHours(4), + 'loaded_unloaded_at' => now()->subHours(3), + 'left_at' => now()->subHours(2.5), + ]); + + ShipmentStop::factory()->create([ + 'shipment_id' => $shipment->id, + 'stop_number' => 2, + 'stop_type' => StopType::Delivery, + 'arrived_at' => now()->subHours(2), + 'loaded_unloaded_at' => now()->subHour(), + 'left_at' => now()->subMinutes(30), + ]); + + $shipment->state->transitionTo(Canceled::class); + expect(get_class($shipment->state))->toBe(Canceled::class); + + // Execute the uncancel action + $action = new UncancelShipment(); + $result = $action->handle($shipment); + + // Assert the shipment state has changed to Delivered + // (Action transitions to Booked, then state service recalculates to Delivered based on stop progress) + expect(get_class($result->state))->toBe(Delivered::class); + expect($result->state->getValue())->toBe('delivered'); +}); + +it('can uncancel a shipment that was only booked not dispatched', function () { + // Create a carrier + $carrier = Carrier::factory()->create([ + 'organization_id' => $this->organization->id, + ]); + + // Create a shipment in Canceled state with a carrier but NO stops at all + // This represents a shipment that was only Booked (carrier assigned) but never Dispatched + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + ]); + + // First transition to Booked to simulate the real workflow + $shipment->state->transitionTo(Booked::class); + // Then cancel it + $shipment->state->transitionTo(Canceled::class); + expect(get_class($shipment->state))->toBe(Canceled::class); + + // Execute the uncancel action + $action = new UncancelShipment(); + $result = $action->handle($shipment); + + // Assert the shipment state has changed to Booked + // (Action transitions to Booked since no stop progress, doesn't auto-dispatch) + expect(get_class($result->state))->toBe(Booked::class); + expect($result->state->getValue())->toBe('booked'); +}); + +it('requires shipment edit permission', function () { + // Create a user without shipment edit permission + $user = User::factory()->create(); + $this->organization->users()->attach($user); + $role = Role::create(['name' => 'tester', 'organization_id' => $this->organization->id]); + $user->assignRole($role); + + // Create a mock request with the unauthorized user + $request = \Mockery::mock(ActionRequest::class); + $request->shouldReceive('user')->andReturn($user); + + // Create the action and check authorization + $action = new UncancelShipment(); + expect($action->authorize($request))->toBeFalse(); + + // Now give the user permission and verify it works + $role->givePermissionTo(Permission::SHIPMENT_EDIT); + expect($action->authorize($request))->toBeTrue(); +}); + +it('returns redirect response when called as controller', function () { + // Create a shipment in Canceled state + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + ]); + $shipment->state->transitionTo(Canceled::class); + + // Create a user with shipment edit permission + $user = User::factory()->create(); + $this->organization->users()->attach($user); + $role = Role::create(['name' => 'editor', 'organization_id' => $this->organization->id]); + $role->givePermissionTo(Permission::SHIPMENT_EDIT); + $user->assignRole($role); + + // Create a mock request with the authorized user + $request = \Mockery::mock(ActionRequest::class); + $request->shouldReceive('user')->andReturn($user); + + // Execute the action as controller + $action = new UncancelShipment(); + $response = $action->asController($request, $shipment); + + // Assert the response is a redirect with success message + expect($response->getContent())->toContain('Redirecting'); + expect($response->isRedirect())->toBeTrue(); +}); diff --git a/tests/Unit/Listeners/Shipments/UpdateShipmentStateTest.php b/tests/Unit/Listeners/Shipments/UpdateShipmentStateTest.php new file mode 100644 index 0000000..adf86d6 --- /dev/null +++ b/tests/Unit/Listeners/Shipments/UpdateShipmentStateTest.php @@ -0,0 +1,748 @@ +setupOrganization(); + $this->actingAs($this->user); +}); + +afterEach(function () { + \Mockery::close(); +}); + +it('remains pending when shipment created without carrier', function () { + // Create shipment without carrier (should remain in Pending state) + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => null, + 'state' => Pending::class, + ]); + + expect(get_class($shipment->state))->toBe(Pending::class); + + // Simulate CreateShipment firing ShipmentCarrierUpdated event + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierUpdated($shipment); + $listener->handle($event); + + $shipment->refresh(); + // Should remain in Pending state when no carrier is assigned + expect(get_class($shipment->state))->toBe(Pending::class); +}); + +it('remains pending when carrier is removed', function () { + // Create shipment with carrier (Booked state) + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => Booked::class, + ]); + + expect(get_class($shipment->state))->toBe(Booked::class); + + // Remove carrier (simulate business scenario where carrier is removed) + $shipment->update(['carrier_id' => null]); + + // Manually trigger the event handler + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + // Should remain in Booked state when carrier is removed (no automatic reversion) + expect(get_class($shipment->state))->toBe(Booked::class); +}); + +it('transitions pending to booked when carrier is assigned', function () { + // Create shipment without carrier (Pending state) + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => null, + 'state' => Pending::class, + ]); + + expect(get_class($shipment->state))->toBe(Pending::class); + + // Create carrier and update shipment carrier details + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + + UpdateShipmentCarrierDetails::run( + shipment: $shipment, + carrierId: $carrier->id, + ); + + // Manually trigger the event handler + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Booked::class); +}); + +it('does not transition if already past pending state', function () { + // Create shipment in Dispatched state + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Dispatched::class, + ]); + + // Try to update carrier + $newCarrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + UpdateShipmentCarrierDetails::run( + shipment: $shipment, + carrierId: $newCarrier->id, + ); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + // Should remain in Dispatched state + expect(get_class($shipment->state))->toBe(Dispatched::class); +}); + +it('transitions booked to dispatched manually', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Booked::class, + ]); + + DispatchShipment::run($shipment); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Dispatched::class); +}); + +it('transitions dispatched to at pickup when driver arrives', function () { + // Create a dispatched shipment with stops + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Dispatched::class, + ]); + + $facility = Facility::factory()->create(['organization_id' => $this->organization->id]); + + $pickupStop = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + 'appointment_at' => now()->addDay(), + ]); + + // Update pickup stop with arrived_at + $pickupStop->update(['arrived_at' => now()]); + + // The model event should fire ShipmentStopsUpdated + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(AtPickup::class); +}); + +it('transitions at pickup to in transit when pickup completed', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => AtPickup::class, + ]); + + $facility = Facility::factory()->create(['organization_id' => $this->organization->id]); + + $pickupStop = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + ]); + + $deliveryStop = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => Facility::factory()->create(['organization_id' => $this->organization->id])->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 2, + ]); + + // Complete pickup stop + $pickupStop->update([ + 'arrived_at' => now()->subHour(), + 'loaded_unloaded_at' => now()->subMinutes(30), + 'left_at' => now()->subMinutes(15), + ]); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(InTransit::class); +}); + +it('transitions in transit to at delivery when arriving', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => InTransit::class, + ]); + + $pickupFacility = Facility::factory()->create(['organization_id' => $this->organization->id]); + $deliveryFacility = Facility::factory()->create(['organization_id' => $this->organization->id]); + + $pickupStop = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $pickupFacility->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + 'arrived_at' => now()->subHours(2), + 'loaded_unloaded_at' => now()->subHours(1), + 'left_at' => now()->subMinutes(45), + ]); + + $deliveryStop = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $deliveryFacility->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 2, + ]); + + // Arrive at delivery + $deliveryStop->update(['arrived_at' => now()]); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(AtDelivery::class); +}); + +it('transitions at delivery to delivered when completed', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => AtDelivery::class, + ]); + + $pickupFacility = Facility::factory()->create(['organization_id' => $this->organization->id]); + $deliveryFacility = Facility::factory()->create(['organization_id' => $this->organization->id]); + + $pickupStop = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $pickupFacility->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + 'arrived_at' => now()->subHours(3), + 'loaded_unloaded_at' => now()->subHours(2), + 'left_at' => now()->subHours(1), + ]); + + $deliveryStop = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $deliveryFacility->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 2, + 'arrived_at' => now()->subMinutes(30), + ]); + + // Complete delivery + $deliveryStop->update(['loaded_unloaded_at' => now()]); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Delivered::class); +}); + +it('transitions booked to pending when carrier bounced', function () { + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => Booked::class, + ]); + + BounceCarrier::run( + shipment: $shipment, + bounceCause: BounceCause::RATE_DISAGREEMENT_RATE_TOO_LOW, + reason: 'Carrier declined the load', + ); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierBounced($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Pending::class); + expect($shipment->carrier_id)->toBeNull(); +}); + +it('transitions dispatched to pending when carrier bounced', function () { + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => Dispatched::class, + ]); + + BounceCarrier::run( + shipment: $shipment, + bounceCause: BounceCause::DRIVER_ILLNESS_EMERGENCY, + reason: 'Driver no show', + ); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierBounced($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Pending::class); +}); + +it('transitions at pickup to pending when carrier bounced', function () { + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => AtPickup::class, + ]); + + BounceCarrier::run( + shipment: $shipment, + bounceCause: BounceCause::MECHANICAL_BREAKDOWN, + reason: 'Truck breakdown', + ); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierBounced($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Pending::class); +}); + +it('does not transition from in transit when carrier bounced', function () { + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => InTransit::class, + ]); + + // Use proper BounceCarrier action instead of direct manipulation + BounceCarrier::run( + shipment: $shipment, + bounceCause: BounceCause::MECHANICAL_BREAKDOWN, + reason: 'Truck breakdown during transit', + ); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierBounced($shipment); + $listener->handle($event); + + $shipment->refresh(); + // Should remain in InTransit + expect(get_class($shipment->state))->toBe(InTransit::class); +}); + +it('can cancel from pending state', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Pending::class, + ]); + + CancelShipment::run($shipment); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Canceled::class); +}); + +it('can cancel from booked state', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Booked::class, + ]); + + CancelShipment::run($shipment); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Canceled::class); +}); + +it('can cancel from delivered state', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Delivered::class, + ]); + + CancelShipment::run($shipment); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Canceled::class); +}); + +it('uncancels to pending when no carrier assigned', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => null, + 'state' => Canceled::class, + ]); + + UncancelShipment::run($shipment); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Pending::class); +}); + +it('uncancels to booked when carrier assigned but no stop progress', function () { + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => Canceled::class, + ]); + + // Create stops without progress + ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + 'arrived_at' => null, + ]); + + UncancelShipment::run($shipment); + + $shipment->refresh(); + expect(get_class($shipment->state))->toBe(Booked::class); +}); + +it('uncancels to calculated state when carrier assigned with stop progress', function () { + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $facility = Facility::factory()->create(['organization_id' => $this->organization->id]); + + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => Canceled::class, + ]); + + // Create stops with progress (driver at pickup) + ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + 'arrived_at' => now(), + 'loaded_unloaded_at' => null, + ]); + + ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => Facility::factory()->create(['organization_id' => $this->organization->id])->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 2, + 'arrived_at' => null, + ]); + + UncancelShipment::run($shipment); + + $shipment->refresh(); + // Should transition to AtPickup based on the stop progress (arrived at pickup) + expect(get_class($shipment->state))->toBe(AtPickup::class); +}); + +it('handles shipment with no stops', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Dispatched::class, + ]); + + // Trigger stop update event even though no stops exist + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment); + $listener->handle($event); + + $shipment->refresh(); + // Should remain in Dispatched when no stops + expect(get_class($shipment->state))->toBe(Dispatched::class); +}); + +it('ignores stop updates for non active states', function () { + $facility = Facility::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Pending::class, + ]); + + $stop = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility->id, + 'stop_type' => StopType::Pickup, + 'arrived_at' => now(), + ]); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment); + $listener->handle($event); + + $shipment->refresh(); + // Should remain in Pending + expect(get_class($shipment->state))->toBe(Pending::class); +}); + +it('handles multi stop shipments correctly', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => Dispatched::class, + ]); + + $facility1 = Facility::factory()->create(['organization_id' => $this->organization->id]); + $facility2 = Facility::factory()->create(['organization_id' => $this->organization->id]); + $facility3 = Facility::factory()->create(['organization_id' => $this->organization->id]); + $facility4 = Facility::factory()->create(['organization_id' => $this->organization->id]); + + // Create multiple pickups and deliveries + $pickup1 = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility1->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + ]); + + $pickup2 = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility2->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 2, + ]); + + $delivery1 = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility3->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 3, + ]); + + $delivery2 = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility4->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 4, + ]); + + // Complete first pickup + $pickup1->update([ + 'arrived_at' => now()->subHours(2), + 'loaded_unloaded_at' => now()->subHour(), + 'left_at' => now()->subMinutes(45), + ]); + + // Arrive at second pickup + $pickup2->update(['arrived_at' => now()]); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + // Should be AtPickup for second pickup + expect(get_class($shipment->state))->toBe(AtPickup::class); +}); + +it('transitions in transit back to at pickup for multi stop', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => InTransit::class, + ]); + + $facility1 = Facility::factory()->create(['organization_id' => $this->organization->id]); + $facility2 = Facility::factory()->create(['organization_id' => $this->organization->id]); + $facility3 = Facility::factory()->create(['organization_id' => $this->organization->id]); + + // Complete first pickup + $pickup1 = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility1->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + 'arrived_at' => now()->subHours(3), + 'loaded_unloaded_at' => now()->subHours(2), + 'left_at' => now()->subHours(1), + ]); + + // Arrive at second pickup (multi-stop scenario) + $pickup2 = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility2->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 2, + 'arrived_at' => now(), + ]); + + $delivery = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility3->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 3, + ]); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + // Should transition back to AtPickup for the second pickup + expect(get_class($shipment->state))->toBe(AtPickup::class); +}); + +it('transitions at delivery back to in transit for multi stop', function () { + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'state' => AtDelivery::class, + ]); + + $facility1 = Facility::factory()->create(['organization_id' => $this->organization->id]); + $facility2 = Facility::factory()->create(['organization_id' => $this->organization->id]); + $facility3 = Facility::factory()->create(['organization_id' => $this->organization->id]); + + // Complete pickup + $pickup = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility1->id, + 'stop_type' => StopType::Pickup, + 'stop_number' => 1, + 'arrived_at' => now()->subHours(4), + 'loaded_unloaded_at' => now()->subHours(3), + 'left_at' => now()->subHours(2), + ]); + + // Complete first delivery + $delivery1 = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility2->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 2, + 'arrived_at' => now()->subHour(), + 'loaded_unloaded_at' => now()->subMinutes(30), + 'left_at' => now()->subMinutes(15), + ]); + + // Second delivery not yet reached (multi-stop scenario) + $delivery2 = ShipmentStop::factory()->create([ + 'organization_id' => $this->organization->id, + 'shipment_id' => $shipment->id, + 'facility_id' => $facility3->id, + 'stop_type' => StopType::Delivery, + 'stop_number' => 3, + ]); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentStopsUpdated($shipment->fresh()); + $listener->handle($event); + + $shipment->refresh(); + // Should transition back to InTransit after completing first delivery with more deliveries remaining + expect(get_class($shipment->state))->toBe(InTransit::class); +}); + +it('prevents carrier bounce from at delivery state', function () { + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => AtDelivery::class, + ]); + + // Use proper BounceCarrier action instead of direct manipulation + BounceCarrier::run( + shipment: $shipment, + bounceCause: BounceCause::MECHANICAL_BREAKDOWN, + reason: 'Truck breakdown at delivery', + ); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierBounced($shipment); + $listener->handle($event); + + $shipment->refresh(); + // Should remain in AtDelivery - cannot bounce once at delivery + expect(get_class($shipment->state))->toBe(AtDelivery::class); +}); + +it('prevents carrier bounce from delivered state', function () { + $carrier = Carrier::factory()->create(['organization_id' => $this->organization->id]); + $shipment = Shipment::factory()->create([ + 'organization_id' => $this->organization->id, + 'carrier_id' => $carrier->id, + 'state' => Delivered::class, + ]); + + // Use proper BounceCarrier action + BounceCarrier::run( + shipment: $shipment, + bounceCause: BounceCause::COMMUNICATION_BREAKDOWN, + reason: 'Late bounce attempt', + ); + + $listener = new UpdateShipmentState(new ShipmentStateService()); + $event = new ShipmentCarrierBounced($shipment); + $listener->handle($event); + + $shipment->refresh(); + // Should remain in Delivered - cannot bounce after delivery + expect(get_class($shipment->state))->toBe(Delivered::class); +}); \ No newline at end of file