diff --git a/server/src/Http/Resources/v1/OrderConfig.php b/server/src/Http/Resources/v1/OrderConfig.php index 5715efacb..7b52fa2e9 100644 --- a/server/src/Http/Resources/v1/OrderConfig.php +++ b/server/src/Http/Resources/v1/OrderConfig.php @@ -41,10 +41,25 @@ public function toArray($request): array } /** - * Project the flow JSON into an ordered list of activities, keeping only - * the public-safe per-activity fields. `code` and `status` (the label) - * are the contract; `complete`, `color`, `details`, `pod_method`, and - * `require_pod` are useful UI hints that can ride along. + * Project the flow JSON into a list of activities, keeping the public-safe + * per-activity fields. + * + * `code` and `status` (the label) are the contract; `complete`, `color`, + * `details`, `pod_method` and `require_pod` are useful UI hints that ride + * along. + * + * `activities`, `sequence` and `logic` describe the flow's *shape*, and are + * published because without them the list cannot be sequenced. The stored + * flow is a directed graph — `activities` names the codes an activity can + * transition to, `sequence` orders activities reachable from the same + * parent, and `logic` gates availability — which is precisely what + * `OrderConfig::nextActivity()` walks server-side. Projecting them away + * left consumers with an unordered set: a client rendering progress from + * array position marks a dispatched order complete whenever `completed` + * happens to be declared earlier, and can offer no next step at all. + * + * These are descriptions of the configured workflow, not internal state, so + * there is nothing here a consumer of the config should not see. */ protected function projectFlow(): array { @@ -66,9 +81,41 @@ protected function projectFlow(): array 'complete' => (bool) ($activity['complete'] ?? false), 'pod_method' => $activity['pod_method'] ?? null, 'require_pod' => (bool) ($activity['require_pod'] ?? false), + 'sequence' => isset($activity['sequence']) ? (int) $activity['sequence'] : null, + 'activities' => static::projectTransitions($activity), + 'logic' => $activity['logic'] ?? null, ]; } return $activities; } + + /** + * The codes an activity can transition to, as a list of strings. + * + * Stored flows have been written both ways over time: a bare list of codes, + * and a list of objects carrying a `code`. Normalising here means consumers + * do not each have to guess which one they were handed. + */ + protected static function projectTransitions(array $activity): array + { + $transitions = $activity['activities'] ?? []; + if (!is_array($transitions)) { + return []; + } + + $codes = []; + foreach ($transitions as $transition) { + if (is_string($transition)) { + $codes[] = $transition; + continue; + } + + if (is_array($transition) && isset($transition['code']) && is_string($transition['code'])) { + $codes[] = $transition['code']; + } + } + + return $codes; + } } diff --git a/server/tests/CompactResourceSerializationTest.php b/server/tests/CompactResourceSerializationTest.php index b482a8c5f..73d3a8331 100644 --- a/server/tests/CompactResourceSerializationTest.php +++ b/server/tests/CompactResourceSerializationTest.php @@ -1400,6 +1400,12 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = 'complete' => false, 'pod_method' => 'photo', 'require_pod' => true, + // The flow's shape now rides along so consumers can sequence + // it. An activity that declares none of it still publishes the + // keys, so the payload shape does not vary per activity. + 'sequence' => null, + 'activities' => [], + 'logic' => null, ], ], ]); @@ -2753,13 +2759,13 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = // scanned code match on that uuid. Debug builds publish the value beside the image so // an automated client can follow the flow without decoding a PNG; production must not. $trackingNumber = fleetopsCompactResourceFixture([ - 'tracking_number' => 'TN-DEBUG', - 'owner_uuid' => 'owner-uuid-under-the-qr', - 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order', - 'region' => 'sg', - 'qr_code' => 'qr-data', - 'barcode' => 'barcode-data', - 'last_status' => 'created', + 'tracking_number' => 'TN-DEBUG', + 'owner_uuid' => 'owner-uuid-under-the-qr', + 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order', + 'region' => 'sg', + 'qr_code' => 'qr-data', + 'barcode' => 'barcode-data', + 'last_status' => 'created', 'last_status_code' => 'CREATED', ]); $request = Request::create('/v1/tracking-numbers', 'GET'); diff --git a/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php b/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php index 7f2d0608f..1ba4af30c 100644 --- a/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +++ b/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php @@ -45,3 +45,94 @@ class FleetOpsSupportRequestState expect($payload['id'])->toBe('order_config_public') ->and($payload['flow'])->toBe([]); }); + +test('order config resource publishes the flow graph so consumers can sequence it', function () { + $request = Request::create('/v1/fleet-ops/order-configs/order_config_public', 'GET'); + FleetOpsOrderConfigResourceRequestState::$request = $request; + FleetOpsSupportRequestState::$request = $request; + + // Declared deliberately out of workflow order, which is how flows are + // stored: `completed` before the step that leads to it, `dispatched` last. + $config = new OrderConfig(); + $config->setRawAttributes([ + 'id' => 8, + 'uuid' => 'order-config-uuid', + 'public_id' => 'order_config_public', + 'key' => 'transport', + 'name' => 'Transport', + 'flow' => [ + ['code' => 'completed', 'status' => 'Order Completed', 'complete' => true, 'activities' => []], + ['code' => 'created', 'status' => 'Order Created', 'activities' => ['dispatched']], + ['code' => 'dispatched', 'status' => 'Order Dispatched', 'activities' => ['enroute'], 'sequence' => 2], + [ + 'code' => 'enroute', + 'status' => 'Driver Enroute', + 'activities' => ['completed', 'failed'], + 'logic' => [['type' => 'and', 'conditions' => []]], + ], + ], + ], true); + + $flow = (new OrderConfigResource($config))->resolve($request)['flow']; + $byCode = collect($flow)->keyBy('code'); + + expect($byCode['created']['activities'])->toBe(['dispatched']) + ->and($byCode['dispatched']['sequence'])->toBe(2) + ->and($byCode['enroute']['activities'])->toBe(['completed', 'failed']) + ->and($byCode['enroute']['logic'])->toBe([['type' => 'and', 'conditions' => []]]) + ->and($byCode['completed']['complete'])->toBeTrue(); +}); + +test('order config resource normalises transitions written as objects', function () { + $request = Request::create('/v1/fleet-ops/order-configs/order_config_public', 'GET'); + FleetOpsOrderConfigResourceRequestState::$request = $request; + FleetOpsSupportRequestState::$request = $request; + + // Flows have been authored both ways; a consumer should not have to guess. + $config = new OrderConfig(); + $config->setRawAttributes([ + 'id' => 9, + 'uuid' => 'order-config-uuid', + 'public_id' => 'order_config_public', + 'key' => 'transport', + 'name' => 'Transport', + 'flow' => [ + ['code' => 'created', 'activities' => [['code' => 'dispatched'], 'enroute', ['label' => 'no code here']]], + // A flow hand-edited down to a single transition can leave a bare + // string where the list belongs. Publish an empty list rather than + // letting the shape vary — a consumer iterating the field should + // never have to type-check it. + ['code' => 'dispatched', 'activities' => 'enroute'], + ], + ], true); + + $flow = (new OrderConfigResource($config))->resolve($request)['flow']; + + expect($flow[0]['activities'])->toBe(['dispatched', 'enroute']) + ->and($flow[1]['activities'])->toBe([]); +}); + +test('order config resource keeps flow fields absent from a legacy activity null rather than missing', function () { + $request = Request::create('/v1/fleet-ops/order-configs/order_config_public', 'GET'); + FleetOpsOrderConfigResourceRequestState::$request = $request; + FleetOpsSupportRequestState::$request = $request; + + $config = new OrderConfig(); + $config->setRawAttributes([ + 'id' => 10, + 'uuid' => 'order-config-uuid', + 'public_id' => 'order_config_public', + 'key' => 'transport', + 'name' => 'Transport', + 'flow' => [['code' => 'created', 'status' => 'Order Created']], + ], true); + + $flow = (new OrderConfigResource($config))->resolve($request)['flow']; + + // Present-and-null is a contract a client can read; absent is one it has to + // feel for. + expect($flow[0])->toHaveKeys(['sequence', 'activities', 'logic']) + ->and($flow[0]['sequence'])->toBeNull() + ->and($flow[0]['activities'])->toBe([]) + ->and($flow[0]['logic'])->toBeNull(); +});