From 676cf462757bcc446b7a002a4e3f7409ad4d5cae Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 22 Aug 2026 23:15:19 +0800 Subject: [PATCH 1/2] feat(api): publish the order config flow's graph so consumers can sequence it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public order-config resource projected each activity down to code, status, details, color, complete, pod_method and require_pod. Those describe an activity but say nothing about the flow's shape, so what reaches an API consumer is an unordered set of activities with no way to put them in order. The stored flow is a directed graph, and the fields that express it were the ones being dropped: `activities` names the codes an activity can transition to, `sequence` orders activities reachable from the same parent, and `logic` gates availability. OrderConfig::nextActivity walks exactly these server-side, and the console's internal resource returns the flow whole, so the gap is only visible from the public API. The consequence is not cosmetic. A client rendering progress from array position marks an order complete whenever `completed` happens to be declared before the order's current activity — the default transport config lists `completed` fourth and `dispatched` last, so a freshly dispatched order shows as finished and offers no next step at all. These describe the configured workflow rather than internal state, so there is nothing here a consumer of the config should not already see. Transitions are normalised to a list of codes, since flows have been authored both as bare codes and as objects carrying one, and the three fields are always present — null or empty rather than absent — so a client can read the contract instead of feeling for it. --- server/src/Http/Resources/v1/OrderConfig.php | 55 +++++++++++- .../Resources/OrderConfigResourceTest.php | 85 +++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) 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/Unit/Http/Resources/OrderConfigResourceTest.php b/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php index 7f2d0608f..585fc25cc 100644 --- a/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +++ b/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php @@ -45,3 +45,88 @@ 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']]], + ], + ], true); + + $flow = (new OrderConfigResource($config))->resolve($request)['flow']; + + expect($flow[0]['activities'])->toBe(['dispatched', 'enroute']); +}); + +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(); +}); From b27d47d627811d602239b7143319004a871cded9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 23 Aug 2026 11:37:26 +0800 Subject: [PATCH 2/2] test(order-config): update the flow contract for the published graph fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact-resource test pinned the pre-change flow entry, so adding sequence, activities and logic to the projection made its whole-array comparison fail and took PHP CI down. Update the expectation to the shape the resource now emits. Also cover projectTransitions' non-array guard, which no fixture reached — it was the one statement standing between the new code and the 100% gate. --- .../CompactResourceSerializationTest.php | 20 ++++++++++++------- .../Resources/OrderConfigResourceTest.php | 10 ++++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) 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 585fc25cc..1ba4af30c 100644 --- a/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +++ b/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php @@ -73,7 +73,7 @@ class FleetOpsSupportRequestState ], ], true); - $flow = (new OrderConfigResource($config))->resolve($request)['flow']; + $flow = (new OrderConfigResource($config))->resolve($request)['flow']; $byCode = collect($flow)->keyBy('code'); expect($byCode['created']['activities'])->toBe(['dispatched']) @@ -98,12 +98,18 @@ class FleetOpsSupportRequestState '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']); + 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 () {