diff --git a/openapi.yaml b/openapi.yaml index 1a243387..ebfe00d6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9421,6 +9421,14 @@ paths: license_plate: type: string description: Required for START command + wash_type: + type: string + enum: [Manual, Machine] + description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services. + wash_mode: + type: string + enum: [manual, machine] + description: Lowercase alias for wash_type accepted by backend clients. customer_number: type: integer description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients. diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php b/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php index dd8510f6..43d168ce 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php @@ -7,6 +7,7 @@ class selfserve_lane_command_arguments public ?string $license_plate = null; public ?int $customer_number = null; public ?int $subuser_id = null; + public ?string $wash_mode = null; public bool $defer_relay_side_effects = false; /** @@ -32,6 +33,22 @@ class selfserve_lane_command_arguments return $this; } + public function setWashMode(?string $wash_mode): self + { + $normalized = strtolower(trim((string)$wash_mode)); + if ($wash_mode === null || $normalized === '') { + $this->wash_mode = null; + return $this; + } + + if (!in_array($normalized, ['manual', 'machine'], true)) { + throw new \InvalidArgumentException('Invalid wash type: ' . $wash_mode); + } + + $this->wash_mode = $normalized; + return $this; + } + public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self { $this->defer_relay_side_effects = $defer_relay_side_effects; @@ -50,6 +67,12 @@ class selfserve_lane_command_arguments if (array_key_exists('subuser_id', $params)) { $this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']); } + if (array_key_exists('wash_type', $params)) { + $this->setWashMode($params['wash_type'] === null ? null : (string)$params['wash_type']); + } + if (array_key_exists('wash_mode', $params)) { + $this->setWashMode($params['wash_mode'] === null ? null : (string)$params['wash_mode']); + } if (array_key_exists('defer_relay_side_effects', $params)) { $this->setDeferRelaySideEffects(filter_var( $params['defer_relay_side_effects'], diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index def685a0..304891bf 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -171,12 +171,12 @@ trait selfserve_lane_command_t * Ensure machine relay is ON when a wash starts, when it is allowed by configuration. * If machine relay is not configured, this is a no-op. */ - protected function setMachineRelayStatusForWashStart(): void + protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void { if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) { return; } - if ($this->isMachineWashSelectedAndAvailableForStart()) { + if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) { try { $this->setMachineRelayStatusHard(true); } catch (\Throwable) { @@ -194,38 +194,39 @@ trait selfserve_lane_command_t /** * Keep the program picker relay aligned with the selected wash mode at START. - * It is ON only when the active self-serve session is allowed to start machine wash. + * It is ON only when the customer explicitly selected machine wash. */ - protected function setProgramPickerRelayStatusForWashStart(): void + protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void { if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) { return; } try { - $shouldEnable = $this->isMachineWashSelectedAndAvailableForStart(); + $shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments) + && $this->isMachineWashSelectedAndAvailableForStart($arguments); $this->setMachineProgramPickerRelayStatusHard($shouldEnable); } catch (\Throwable) { // Best effort only; wash start must continue. } } - protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(): void + protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void { if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) { return; } try { - $this->setMachineProgramPickerRelayStatusHard($this->isMachineServiceSelectedForWashStart()); + $this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments)); } catch (\Throwable) { // Best effort only; wash start must continue. } } - protected function isMachineWashSelectedAndAvailableForStart(): bool + protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool { - if (!$this->isMachineServiceSelectedForWashStart()) { + if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) { return false; } @@ -248,6 +249,39 @@ trait selfserve_lane_command_t } } + protected function shouldEnableSelectedMachineServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): bool + { + if (!$this->isMachineWashModeSelectedForStart($arguments)) { + return false; + } + + return $this->isMachineServiceSelectedForWashStart(); + } + + protected function shouldEnableProgramPickerRelayForWashStart(?selfserve_lane_command_arguments $arguments = null): bool + { + return $this->isExplicitMachineWashModeSelectedForStart($arguments) + && $this->isMachineServiceSelectedForWashStart(); + } + + protected function isExplicitMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool + { + return $arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE; + } + + protected function isMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool + { + if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MANUAL) { + return false; + } + + if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE) { + return true; + } + + return $this->isMachineServiceSelectedForWashStart(); + } + protected function isMachineServiceSelectedForWashStart(): bool { try { @@ -349,17 +383,24 @@ trait selfserve_lane_command_t protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void { if ($arguments->defer_relay_side_effects) { - $this->setProgramPickerRelayStatusFromSelectedServiceForWashStart(); + $this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments); return; } $this->turnOnCleanerRelayForWashStart(); - $this->setProgramPickerRelayStatusForWashStart(); - $this->setMachineRelayStatusForWashStart(); + $this->setProgramPickerRelayStatusForWashStart($arguments); + $this->setMachineRelayStatusForWashStart($arguments); } - protected function resolveSelfServeActionWashModeForStart(): string + protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string { + if ($arguments !== null && in_array($arguments->wash_mode, [ + selfserve_studio_actions::MODE_MANUAL, + selfserve_studio_actions::MODE_MACHINE, + ], true)) { + return $arguments->wash_mode; + } + if ($this->isMachineServiceSelectedForWashStart()) { return selfserve_studio_actions::MODE_MACHINE; } @@ -634,7 +675,7 @@ trait selfserve_lane_command_t $this->runRelaySideEffectsForWashStart($arguments); $this->runPublishedStudioActions( selfserve_studio_actions::EVENT_WASH_START_COMMAND, - $this->resolveSelfServeActionWashModeForStart(), + $this->resolveSelfServeActionWashModeForStart($arguments), [ 'customer_number' => (int)$customer_number, 'reg' => $license_plate, diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 1a243387..ebfe00d6 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -9421,6 +9421,14 @@ paths: license_plate: type: string description: Required for START command + wash_type: + type: string + enum: [Manual, Machine] + description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services. + wash_mode: + type: string + enum: [manual, machine] + description: Lowercase alias for wash_type accepted by backend clients. customer_number: type: integer description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients. diff --git a/services/nginx/app/tests/Api/SelfserveCustomerStartApiTest.php b/services/nginx/app/tests/Api/SelfserveCustomerStartApiTest.php index fdb06877..f7e4b398 100644 --- a/services/nginx/app/tests/Api/SelfserveCustomerStartApiTest.php +++ b/services/nginx/app/tests/Api/SelfserveCustomerStartApiTest.php @@ -76,6 +76,7 @@ it('allows the customer self-serve start sequence without department access', fu 'lane_id' => $laneId, 'command' => 'START', 'license_plate' => $reg, + 'wash_type' => 'Manual', 'defer_relay_side_effects' => true, ], $headers) ->assertStatus(200) @@ -131,6 +132,7 @@ it('marks the active customer session relay-enabled after machine relay enable', 'lane_id' => $laneId, 'command' => 'START', 'license_plate' => $reg, + 'wash_type' => 'Machine', 'defer_relay_side_effects' => true, ], $headers) ->assertStatus(200) diff --git a/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php index 4ffdd7d4..65f0cfb0 100644 --- a/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php +++ b/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php @@ -64,6 +64,7 @@ it('allows customer self-serve permission to execute START without department ac 'lane_id' => (int)$scenario['lane']['id'], 'command' => 'START', 'license_plate' => (string)$scenario['vehicle']['reg'], + 'wash_type' => 'Manual', 'defer_relay_side_effects' => true, ], api_fixtures()->bearerHeaders($token)); @@ -170,6 +171,7 @@ it('still allows elevated operators with department access to execute lane comma 'lane_id' => (int)$scenario['lane']['id'], 'command' => 'START', 'license_plate' => 'OP' . (int)$scenario['lane']['id'], + 'wash_type' => 'Manual', 'defer_relay_side_effects' => true, ], $session['headers']); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php index e87c5df7..ca37da28 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php @@ -53,20 +53,23 @@ class SelfserveLaneStartEntranceTimeoutHarness $this->relayEvents[] = 'cleaner:on'; } - protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(): void + protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void { + unset($arguments); $this->programPickerRelayCalls++; $this->relayEvents[] = 'program_picker:selected_service'; } - protected function setProgramPickerRelayStatusForWashStart(): void + protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void { + unset($arguments); $this->programPickerRelayCalls++; $this->relayEvents[] = 'program_picker:eligibility_sync'; } - protected function setMachineRelayStatusForWashStart(): void + protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void { + unset($arguments); $this->machineRelayCalls++; $this->relayEvents[] = 'machine:sync'; } @@ -107,14 +110,30 @@ it('parses deferred relay side effects on start command arguments', function (): $arguments = (new selfserve_lane_command_arguments())->setParameters([ 'license_plate' => 'ab12345', 'customer_number' => 12345679, + 'wash_type' => 'Manual', 'defer_relay_side_effects' => true, ]); expect($arguments->license_plate)->toBe('AB12345'); expect($arguments->customer_number)->toBe(12345679); + expect($arguments->wash_mode)->toBe('manual'); expect($arguments->defer_relay_side_effects)->toBeTrue(); }); +it('parses wash mode aliases on start command arguments', function (): void { + $arguments = (new selfserve_lane_command_arguments())->setParameters([ + 'wash_mode' => 'machine', + ]); + + expect($arguments->wash_mode)->toBe('machine'); +}); + +it('rejects invalid wash types on start command arguments', function (): void { + expect(fn() => (new selfserve_lane_command_arguments())->setParameters([ + 'wash_type' => 'automatic', + ]))->toThrow(\InvalidArgumentException::class, 'Invalid wash type: automatic'); +}); + it('only syncs program picker from selected service when start asks to defer machine side effects', function (): void { $lane = new SelfserveLaneStartEntranceTimeoutHarness(); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 3d937789..d6c99b2c 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -138,6 +138,8 @@ it('documents property gate lane commands and sanitized gate failure responses', expect($commandPathBlock)->toContain('OPEN_PROPERTY_ACCESS_GATE'); expect($commandPathBlock)->toContain('OPEN_PROPERTY_EXIT_GATE'); + expect($commandPathBlock)->toContain('wash_type:'); + expect($commandPathBlock)->toContain('wash_mode:'); expect($commandPathBlock)->toContain('Command execution failed'); expect($commandPathBlock)->toContain('Failed to execute command: Failed to open property access gate.'); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveProgramPickerWashTypeSelectionTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveProgramPickerWashTypeSelectionTest.php index 31aacb01..399caa2b 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveProgramPickerWashTypeSelectionTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveProgramPickerWashTypeSelectionTest.php @@ -41,6 +41,8 @@ class SelfserveProgramPickerSelectionHarness public object $department_lane; public int $licensePlateReads = 0; public int $customerNumberReads = 0; + public int $availabilityChecks = 0; + public bool $machineAvailable = true; /** @var array */ public array $programPickerWrites = []; /** @var array */ @@ -79,29 +81,84 @@ class SelfserveProgramPickerSelectionHarness return true; } - public function runDeferredStartRelaySideEffects(): void + public function runDeferredStartRelaySideEffects(?string $washType = null): void { - $arguments = (new selfserve_lane_command_arguments())->setDeferRelaySideEffects(true); + $arguments = (new selfserve_lane_command_arguments()) + ->setDeferRelaySideEffects(true) + ->setWashMode($washType); $this->runRelaySideEffectsForWashStart($arguments); } + + public function runNormalStartProgramPickerRelay(?string $washType = null): void + { + $arguments = (new selfserve_lane_command_arguments())->setWashMode($washType); + $this->setProgramPickerRelayStatusForWashStart($arguments); + } + + public function resolveStartWashMode(?string $washType = null): string + { + $arguments = (new selfserve_lane_command_arguments())->setWashMode($washType); + return $this->resolveSelfServeActionWashModeForStart($arguments); + } + + protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool + { + unset($arguments); + $this->availabilityChecks++; + return $this->machineAvailable; + } } it('turns off the program picker on deferred start when the frontend selected manual wash', function (): void { $lane = new SelfserveProgramPickerSelectionHarness(); $lane->setSelectedServices([]); - $lane->runDeferredStartRelaySideEffects(); + $lane->runDeferredStartRelaySideEffects('Manual'); expect($lane->programPickerWrites)->toBe([false]); }); it('does not let backend machine eligibility override a frontend manual wash selection', function (): void { $lane = new SelfserveProgramPickerSelectionHarness(); - $lane->setSelectedServices([]); + $lane->setSelectedServices(['MACHINE']); - $lane->runDeferredStartRelaySideEffects(); + $lane->runDeferredStartRelaySideEffects('Manual'); expect($lane->licensePlateReads)->toBe(0) ->and($lane->customerNumberReads)->toBe(0) - ->and($lane->programPickerWrites)->toBe([false]); + ->and($lane->programPickerWrites)->toBe([false]) + ->and($lane->resolveStartWashMode('Manual'))->toBe('manual'); +}); + +it('does not infer program picker enablement from machine service without a customer machine selection', function (): void { + $lane = new SelfserveProgramPickerSelectionHarness(); + $lane->setSelectedServices(['MACHINE']); + + $lane->runDeferredStartRelaySideEffects(); + $lane->runNormalStartProgramPickerRelay(); + + expect($lane->programPickerWrites)->toBe([false, false]) + ->and($lane->availabilityChecks)->toBe(0); +}); + +it('keeps normal start program picker off when the customer selected manual wash', function (): void { + $lane = new SelfserveProgramPickerSelectionHarness(); + $lane->setSelectedServices(['MACHINE']); + + $lane->runNormalStartProgramPickerRelay('Manual'); + + expect($lane->programPickerWrites)->toBe([false]) + ->and($lane->availabilityChecks)->toBe(0); +}); + +it('honors a frontend machine wash selection when machine service is selected', function (): void { + $lane = new SelfserveProgramPickerSelectionHarness(); + $lane->setSelectedServices(['MACHINE']); + + $lane->runDeferredStartRelaySideEffects('Machine'); + $lane->runNormalStartProgramPickerRelay('Machine'); + + expect($lane->programPickerWrites)->toBe([true, true]) + ->and($lane->availabilityChecks)->toBe(1) + ->and($lane->resolveStartWashMode('Machine'))->toBe('machine'); });