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