Merge pull request #278 from copenhagentruckwash/fix/self-serve-start-wash-type

Honor wash type in self-serve lane start
This commit is contained in:
Jeppe B
2026-06-10 20:07:46 +02:00
committed by GitHub
9 changed files with 185 additions and 23 deletions
@@ -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'],
@@ -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,
+8
View File
@@ -9448,6 +9448,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.
@@ -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)
@@ -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']);
@@ -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();
@@ -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.');
@@ -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<int,bool> */
public array $programPickerWrites = [];
/** @var array<string,mixed> */
@@ -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');
});