Add unit tests for legacy schema compatibility, property gate commands, lane state transitions, and relay synchronization. Extend relay logic with demo relay handling, dynamic image updates, phone normalization, and machine relay hard set methods.
This commit is contained in:
@@ -97,6 +97,7 @@ class selfserve_schema_bootstrap
|
||||
description VARCHAR(255) NULL,
|
||||
services JSON NULL,
|
||||
buttons JSON NULL,
|
||||
dynamic_image_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
@@ -108,9 +108,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||
]);
|
||||
|
||||
if ($activateMachine && (bool)$snapshot['allowed']) {
|
||||
$this->enableMachineRelayIfAllowed($snapshot, $session);
|
||||
}
|
||||
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
||||
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
@@ -475,8 +473,6 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
|
||||
$laneId = (int)$snapshot['lane']['id'];
|
||||
$lane = (new selfserve())->lane($laneId);
|
||||
$lane->setLaneCache($laneId, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $snapshot['allowed_services']);
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE);
|
||||
$this->enableCleanerRelayForStartedWash($lane);
|
||||
|
||||
$session->markRelayEnabled();
|
||||
@@ -488,6 +484,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
]);
|
||||
}
|
||||
|
||||
protected function syncMachineRelayFromVisibleServices(array $snapshot, selfserve_wash_sessions_o $session, bool $allowEnable): void
|
||||
{
|
||||
$laneId = (int)$snapshot['lane']['id'];
|
||||
$lane = (new selfserve())->lane($laneId);
|
||||
$sync = $lane->syncMachineRelayFromVisibleServices(
|
||||
is_array($snapshot['allowed_services'] ?? null) ? $snapshot['allowed_services'] : [],
|
||||
$allowEnable
|
||||
);
|
||||
|
||||
if (($sync['relay_action'] ?? '') === 'enabled') {
|
||||
$this->enableMachineRelayIfAllowed($snapshot, $session);
|
||||
}
|
||||
|
||||
$relayTargetOn = (bool)($sync['relay_target_on'] ?? false);
|
||||
if (!$relayTargetOn && (bool)$session->machine_relay_enabled->value() === true) {
|
||||
$session->markRelayDisabled();
|
||||
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
|
||||
}
|
||||
}
|
||||
|
||||
protected function deriveBaseStatus(array $snapshot): selfserve_wash_session_status
|
||||
{
|
||||
if (!$snapshot['all_visible_questions_answered']) {
|
||||
@@ -720,6 +736,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
(string)$task['description'],
|
||||
$task['services'],
|
||||
$task['buttons'],
|
||||
$task['dynamic_images_vehicle_type'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -986,4 +1003,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function isMachineAllowedToStartWash(int $id): bool
|
||||
{
|
||||
$sessionSummary = $this->getSessionSummary($id);
|
||||
return ($sessionSummary['session']['allowed'] ?? false) === true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ enum selfserve_lane_command
|
||||
case RESET; // command to reset the lane
|
||||
case RESERVE; // command to reserve the lane
|
||||
case RELEASE; // command to release the lane (from reservation)
|
||||
case OPEN_PROPERTY_ACCESS_GATE; // command to open the outer gate (when applicable) to allow access to the physical property premises.
|
||||
case OPEN_PROPERTY_EXIT_GATE; // command to open the outer gate (when applicable) to allow exit from the physical property premises.
|
||||
|
||||
public static function tryFrom(string $commandParam): ?selfserve_lane_command
|
||||
{
|
||||
@@ -18,6 +20,8 @@ enum selfserve_lane_command
|
||||
'RESET' => selfserve_lane_command::RESET,
|
||||
'RESERVE' => selfserve_lane_command::RESERVE,
|
||||
'RELEASE' => selfserve_lane_command::RELEASE,
|
||||
'OPEN_PROPERTY_ACCESS_GATE' => selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE,
|
||||
'OPEN_PROPERTY_EXIT_GATE' => selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
use Exception;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
||||
use modules\selfserve\classes\selfserve_wash_flow;
|
||||
use modules\selfserve\helpers\selfserve_lane_command;
|
||||
use modules\selfserve\helpers\selfserve_lane_log_action;
|
||||
use modules\selfserve\helpers\selfserve_lane_mode;
|
||||
@@ -21,6 +22,7 @@ use modules\selfserve\helpers\selfserve_lane_port;
|
||||
use modules\selfserve\helpers\selfserve_lane_state;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use modules\selfserve\helpers\selfserve_lane_relay;
|
||||
use objects\department_gates_o;
|
||||
use objects\users_o;
|
||||
use objects\department_variables_o;
|
||||
|
||||
@@ -93,25 +95,56 @@ trait selfserve_lane_command_t
|
||||
// Best effort only; wash start must continue.
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
||||
return;
|
||||
}
|
||||
$active_wash = new selfserve_wash_flow();
|
||||
if ($active_wash->isMachineAllowedToStartWash($this->id)) {
|
||||
try {
|
||||
$this->setMachineRelayStatusHard(true);
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; wash start must continue.
|
||||
}
|
||||
} else {
|
||||
// Turning on the machine relay is not allowed by configuration, so ensure it is OFF.
|
||||
try {
|
||||
$this->setMachineRelayStatusHard(false);
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; wash start must continue.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Disable relays after STOP in deterministic order:
|
||||
* cleaner -> machine -> program selector.
|
||||
* 1. Cleaner relay
|
||||
* 2. Program selector relay
|
||||
* 3. Machine relay
|
||||
*/
|
||||
protected function turnOffRelaysAfterStop(): void
|
||||
{
|
||||
foreach ([
|
||||
$relays = [
|
||||
selfserve_lane_relay::MACHINE_CLEANER,
|
||||
selfserve_lane_relay::MACHINE,
|
||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
||||
] as $relay) {
|
||||
selfserve_lane_relay::MACHINE,
|
||||
];
|
||||
|
||||
foreach ($relays as $relay) {
|
||||
if (!$this->isRelayConfigured($relay)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->turnOffRelay($relay);
|
||||
$this->setRelayStatusHard($relay, false);
|
||||
} catch (\Throwable) {
|
||||
// Best effort relay shutdown; never block STOP.
|
||||
// Continue attempting to turn off remaining relays.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +180,49 @@ trait selfserve_lane_command_t
|
||||
// Session completion must not block STOP flow.
|
||||
}
|
||||
}
|
||||
|
||||
protected function executeOpenPropertyGateCommand(bool $isAccessGate): void
|
||||
{
|
||||
$commandLabel = $isAccessGate ? 'access' : 'exit';
|
||||
$gateLabel = $isAccessGate ? 'entrance' : 'exit';
|
||||
|
||||
if (!$this->isDepartmentSelfServeEnabled()) {
|
||||
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane\'s department.');
|
||||
}
|
||||
if (empty($this->department_lane) || empty($this->department_lane->department)) {
|
||||
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.');
|
||||
}
|
||||
|
||||
$department_id = (int)$this->department_lane->department->value();
|
||||
if ($department_id <= 0) {
|
||||
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.');
|
||||
}
|
||||
|
||||
$gate = $this->resolveDepartmentGateForCommand($department_id, $isAccessGate);
|
||||
if ($gate === null || !$gate->exists()) {
|
||||
throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: No ' . $gateLabel . ' gate configured for this lane\'s department.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->openDepartmentGateForCommand($gate);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('Failed to open property ' . $commandLabel . ' gate: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
protected function resolveDepartmentGateForCommand(int $department_id, bool $isAccessGate): ?department_gates_o
|
||||
{
|
||||
$department_gates = new department_gates_o();
|
||||
return $isAccessGate
|
||||
? $department_gates->getEntranceGate($department_id)
|
||||
: $department_gates->getExitGate($department_id);
|
||||
}
|
||||
|
||||
protected function openDepartmentGateForCommand(department_gates_o $gate): void
|
||||
{
|
||||
$gate->openGate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command on a self-serve lane
|
||||
* @param selfserve_lane_command $command The command to execute
|
||||
@@ -210,6 +286,8 @@ trait selfserve_lane_command_t
|
||||
$this->setWashStartTime(time());
|
||||
// Ensure cleaner relay is enabled whenever wash starts.
|
||||
$this->turnOnCleanerRelayForWashStart();
|
||||
// Ensure the machine relay is ON when a wash starts, when it is allowed.
|
||||
$this->setMachineRelayStatusForWashStart();
|
||||
// Log the lane start event
|
||||
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
||||
break;
|
||||
@@ -247,6 +325,12 @@ trait selfserve_lane_command_t
|
||||
$this->setLicensePlate(self::DEFAULT_LICENSE_PLATE);
|
||||
$this->setReservationStartTime(null);
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
||||
$this->executeOpenPropertyGateCommand(true);
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
|
||||
$this->executeOpenPropertyGateCommand(false);
|
||||
break;
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unknown command: " . $command->name);
|
||||
}
|
||||
|
||||
@@ -37,4 +37,16 @@ trait selfserve_lane_customer_number_t
|
||||
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_CUSTOMER_NUMBER, $this->customer_number);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the customer number associated with the current wash
|
||||
* @notation Should be used when the wash is complete and the customer number is no longer needed
|
||||
* @return selfserve_lane_customer_number_t|selfserve_lane
|
||||
*/
|
||||
public function clearCustomerNumber(): self
|
||||
{
|
||||
$this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_CUSTOMER_NUMBER);
|
||||
$this->customer_number = self::DEFAULT_CUSTOMER_NUMBER;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use modules\shelly\helpers\shelly_device_switch;
|
||||
|
||||
trait selfserve_lane_port_controller_t
|
||||
{
|
||||
private const DEMO_RELAY_ID_PREFIX = 'demo-';
|
||||
|
||||
/**
|
||||
* Open the lane port
|
||||
* @param selfserve_lane_port $port The port to open (ENTRANCE or EXIT)
|
||||
@@ -67,12 +69,23 @@ trait selfserve_lane_port_controller_t
|
||||
if (empty($relay_id)) {
|
||||
throw new \Exception("Invalid relay ID for port {$port->name}");
|
||||
}
|
||||
|
||||
if ($this->isDemoPortRelayId((string)$relay_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$device = $this->createShellySwitchDevice();
|
||||
$device->id = (string)$relay_id;
|
||||
$device->switch(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isDemoPortRelayId(string $relay_id): bool
|
||||
{
|
||||
$normalized = strtolower(trim($relay_id));
|
||||
return $normalized !== '' && str_starts_with($normalized, self::DEMO_RELAY_ID_PREFIX);
|
||||
}
|
||||
|
||||
protected function createShellySwitchDevice(): shelly_device_switch
|
||||
{
|
||||
return new shelly_device_switch();
|
||||
|
||||
@@ -19,6 +19,7 @@ trait selfserve_lane_relay_controller_t
|
||||
private const SHELLY_STATUS_SNAPSHOT_TTL_SECONDS = 1;
|
||||
private const SHELLY_STATUS_SNAPSHOT_KEY_PREFIX = 'selfserve_lane_shelly_status_snapshot_';
|
||||
private const SHELLY_DEFAULT_CHANNEL = 0;
|
||||
private const DEMO_RELAY_ID_PREFIX = 'demo-';
|
||||
|
||||
/**
|
||||
* Get current MACHINE relay status from Shelly.
|
||||
@@ -158,6 +159,85 @@ trait selfserve_lane_relay_controller_t
|
||||
return $this->sendRelaySwitchCommand($relay, $on);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize MACHINE relay state from currently visible task services.
|
||||
*
|
||||
* - Always persists normalized services in lane cache.
|
||||
* - Enables MACHINE only when visible and allowEnable=true (guarded path).
|
||||
* - Disables MACHINE immediately when not visible (hard OFF path).
|
||||
*
|
||||
* @param array<int,mixed> $allowedServices
|
||||
* @return array{
|
||||
* machine_visible: bool,
|
||||
* relay_action: string,
|
||||
* relay_target_on: bool
|
||||
* }
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function syncMachineRelayFromVisibleServices(array $allowedServices, bool $allowEnable = true): array
|
||||
{
|
||||
$normalizedServices = $this->normalizeVisibleServiceNames($allowedServices);
|
||||
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $normalizedServices);
|
||||
|
||||
$machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true);
|
||||
$relayAction = 'noop';
|
||||
|
||||
if (!$this->hasConfiguredRelay(selfserve_lane_relay::MACHINE)) {
|
||||
return [
|
||||
'machine_visible' => $machineVisible,
|
||||
'relay_action' => 'noop_missing_machine_relay',
|
||||
'relay_target_on' => $machineVisible,
|
||||
];
|
||||
}
|
||||
|
||||
if ($machineVisible) {
|
||||
if ($allowEnable) {
|
||||
$this->turnOnRelay(selfserve_lane_relay::MACHINE);
|
||||
$relayAction = 'enabled';
|
||||
} else {
|
||||
$relayAction = 'noop_enable_blocked';
|
||||
}
|
||||
} else {
|
||||
$this->setRelayStatusHard(selfserve_lane_relay::MACHINE, false);
|
||||
$relayAction = 'disabled';
|
||||
}
|
||||
|
||||
return [
|
||||
'machine_visible' => $machineVisible,
|
||||
'relay_action' => $relayAction,
|
||||
'relay_target_on' => $machineVisible,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $services
|
||||
* @return string[]
|
||||
*/
|
||||
private function normalizeVisibleServiceNames(array $services): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($services as $service) {
|
||||
$name = strtoupper(trim((string)$service));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($name, $normalized, true)) {
|
||||
$normalized[] = $name;
|
||||
}
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function hasConfiguredRelay(selfserve_lane_relay $relay): bool
|
||||
{
|
||||
try {
|
||||
$relayId = trim($this->getRelayId($relay));
|
||||
return $relayId !== '';
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relay ID for the current lane.
|
||||
* @param selfserve_lane_relay $relay
|
||||
@@ -231,12 +311,28 @@ trait selfserve_lane_relay_controller_t
|
||||
private function fetchLaneShellyStatusSnapshotWithReadiness(array $required_relay_ids): array
|
||||
{
|
||||
$relay_ids = $this->getConfiguredLaneRelayIds();
|
||||
$cloud_relay_ids = array_values(array_filter(
|
||||
$relay_ids,
|
||||
fn(string $relay_id): bool => !$this->isDemoRelayId($relay_id)
|
||||
));
|
||||
$demo_relay_ids = array_values(array_filter(
|
||||
$relay_ids,
|
||||
fn(string $relay_id): bool => $this->isDemoRelayId($relay_id)
|
||||
));
|
||||
|
||||
if (count($cloud_relay_ids) < 1) {
|
||||
return $this->appendDemoRelaySnapshots([], $relay_ids);
|
||||
}
|
||||
|
||||
$deadline = $this->nowTimestamp() + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
|
||||
$last_reason = 'Shelly relay status is not ready yet';
|
||||
|
||||
do {
|
||||
$result = $this->sendShellyPostRateLimited('/v2/devices/api/get', $this->buildStatusGetPayload($relay_ids));
|
||||
$snapshot = $this->mapResponseToRelaySnapshot($result);
|
||||
$result = $this->sendShellyPostRateLimited('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids));
|
||||
$snapshot = $this->appendDemoRelaySnapshots(
|
||||
$this->mapResponseToRelaySnapshot($result),
|
||||
$demo_relay_ids
|
||||
);
|
||||
|
||||
if ($this->snapshotHasUsablePayload($snapshot, $required_relay_ids)) {
|
||||
return $snapshot;
|
||||
@@ -263,6 +359,53 @@ trait selfserve_lane_relay_controller_t
|
||||
return (array)$parameters;
|
||||
}
|
||||
|
||||
private function isDemoRelayId(string $relay_id): bool
|
||||
{
|
||||
$normalized = strtolower(trim($relay_id));
|
||||
return $normalized !== '' && str_starts_with($normalized, self::DEMO_RELAY_ID_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array> $snapshot
|
||||
* @param string[] $relay_ids
|
||||
* @return array<string,array>
|
||||
*/
|
||||
private function appendDemoRelaySnapshots(array $snapshot, array $relay_ids): array
|
||||
{
|
||||
foreach ($relay_ids as $relay_id) {
|
||||
if (!$this->isDemoRelayId($relay_id)) {
|
||||
continue;
|
||||
}
|
||||
$snapshot[$relay_id] = $this->getDemoRelaySnapshotEntry($relay_id);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, online: bool, on: bool, status: array<string,array<string,bool>>}
|
||||
*/
|
||||
private function buildDemoRelaySnapshotEntry(string $relay_id, bool $on): array
|
||||
{
|
||||
return [
|
||||
'id' => $relay_id,
|
||||
'online' => true,
|
||||
'on' => $on,
|
||||
'status' => ['switch:0' => ['output' => $on]],
|
||||
];
|
||||
}
|
||||
|
||||
private function getDemoRelaySnapshotEntry(string $relay_id): array
|
||||
{
|
||||
$snapshot = $this->getCachedLaneShellyStatusSnapshot() ?? [];
|
||||
$entry = $snapshot[$relay_id] ?? null;
|
||||
if (is_array($entry) && $this->relayStatusPayloadExists($entry)) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
return $this->buildDemoRelaySnapshotEntry($relay_id, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|object|null $response
|
||||
* @return array<string,array>
|
||||
@@ -720,7 +863,9 @@ trait selfserve_lane_relay_controller_t
|
||||
// Keep duration parameter for route compatibility, but do not pass toggle_after.
|
||||
// Some Shelly firmware variants interpret toggle_after=0 as immediate toggle.
|
||||
|
||||
$response = $this->sendShellyPostRateLimited('/v2/devices/api/set/switch', $payload);
|
||||
$response = $this->isDemoRelayId($relay_id)
|
||||
? [$this->buildDemoRelaySnapshotEntry($relay_id, $on)]
|
||||
: $this->sendShellyPostRateLimited('/v2/devices/api/set/switch', $payload);
|
||||
$this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\department_gate_config;
|
||||
use classes\bird;
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\selfserve;
|
||||
use classes\slack;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
|
||||
@@ -167,4 +168,116 @@ class department_gates_o extends db
|
||||
|
||||
return (new department_gates_o())->select((int)$exit_gate_data[0]['id']);
|
||||
}
|
||||
|
||||
public static function normalizePhoneCandidate(mixed $candidate): ?array
|
||||
{
|
||||
if (is_array($candidate)) {
|
||||
$phone = $candidate['phone_number'] ?? null;
|
||||
if ($phone !== null) {
|
||||
$country = self::extractCountryCodeFromPhoneNumber((string)$phone);
|
||||
return self::normalizePhone((string)$phone, $country);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($candidate) && trim($candidate) !== '') {
|
||||
return self::normalizePhone($candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function normalizePhone(string $raw, ?int $defaultCountryCode = 45): ?array
|
||||
{
|
||||
$trimmed = trim($raw);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
$digits = preg_replace('/\D+/', '', $trimmed);
|
||||
if (!is_string($digits) || $digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$country = $defaultCountryCode;
|
||||
$phone = $digits;
|
||||
|
||||
if (str_starts_with($trimmed, '+')) {
|
||||
$extractedCountry = self::extractCountryCodeFromPhoneNumber($trimmed);
|
||||
if ($extractedCountry !== null) {
|
||||
$country = $extractedCountry;
|
||||
$phone = substr($digits, strlen((string)$extractedCountry));
|
||||
} elseif (strlen($digits) > 8) {
|
||||
$country = (int)substr($digits, 0, 2);
|
||||
$phone = substr($digits, 2);
|
||||
}
|
||||
} elseif ($country !== null && str_starts_with($digits, (string)$country) && strlen($digits) > 8) {
|
||||
$phone = substr($digits, strlen((string)$country));
|
||||
}
|
||||
|
||||
if ($country === null || $country <= 0 || $phone === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$country, (int)$phone];
|
||||
}
|
||||
|
||||
public static function extractCountryCodeFromPhoneNumber(string $phone): ?int
|
||||
{
|
||||
if (str_starts_with($phone, '+45')) {
|
||||
return 45;
|
||||
}
|
||||
if (str_starts_with($phone, '+46')) {
|
||||
return 46;
|
||||
}
|
||||
if (str_starts_with($phone, '+47')) {
|
||||
return 47;
|
||||
}
|
||||
if (str_starts_with($phone, '+358')) {
|
||||
return 358;
|
||||
}
|
||||
if (str_starts_with($phone, '+49')) {
|
||||
return 49;
|
||||
}
|
||||
if (str_starts_with($phone, '+44')) {
|
||||
return 44;
|
||||
}
|
||||
if (str_starts_with($phone, '+1')) {
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function openGate(): void
|
||||
{
|
||||
$this->requireSelected();
|
||||
$config = (array)$this->config->value();
|
||||
|
||||
if (!isset($config['type']) || $config['type'] !== 'PHONE_CALL') {
|
||||
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? ''));
|
||||
}
|
||||
if (!isset($config['phone_number'])) {
|
||||
throw new Exception('Phone number is required for PHONE_CALL gate type');
|
||||
}
|
||||
|
||||
$normalized = self::normalizePhoneCandidate($config['phone_number']);
|
||||
if ($normalized === null) {
|
||||
throw new Exception('Invalid phone number for PHONE_CALL gate type');
|
||||
}
|
||||
[$countryCode, $phone] = $normalized;
|
||||
|
||||
$timeout = (int)($config['call_duration_threshold'] ?? 10);
|
||||
|
||||
$client = new bird();
|
||||
try {
|
||||
$client->callGateAndHangupWhenAccepted(
|
||||
(int)$countryCode,
|
||||
(int)$phone,
|
||||
$timeout,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$slack = new slack();
|
||||
$slack->send_message('Failed to call gate for phone ' . $countryCode . $phone . ': ' . $e->getMessage(), 'Bird Voice Call Webhooks');
|
||||
throw new Exception('Failed to open gate relay via phone call', 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class selfserve_wash_session_tasks_o extends db
|
||||
public object_property $description;
|
||||
public object_property $services;
|
||||
public object_property $buttons;
|
||||
public object_property $dynamic_images_vehicle_type;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
@@ -35,6 +36,7 @@ class selfserve_wash_session_tasks_o extends db
|
||||
$this->description = new object_property($this->table, $this->id, 'description', 'string', false);
|
||||
$this->services = new object_property($this->table, $this->id, 'services', 'json', false);
|
||||
$this->buttons = new object_property($this->table, $this->id, 'buttons', 'json', false);
|
||||
$this->dynamic_images_vehicle_type = new object_property($this->table, $this->id, 'dynamic_images_vehicle_type', 'string', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
@@ -51,7 +53,8 @@ class selfserve_wash_session_tasks_o extends db
|
||||
string $taskText,
|
||||
?string $description = null,
|
||||
?array $services = null,
|
||||
?array $buttons = null
|
||||
?array $buttons = null,
|
||||
?int $thumb_position = null,
|
||||
): self {
|
||||
$this->id = self::add_object([
|
||||
'session_id' => $sessionId,
|
||||
@@ -60,6 +63,7 @@ class selfserve_wash_session_tasks_o extends db
|
||||
'description' => $description,
|
||||
'services' => $services,
|
||||
'buttons' => $buttons,
|
||||
'dynamic_images_vehicle_type' => $thumb_position, // The rotations to do on the image.
|
||||
]);
|
||||
$this->getObjectProperties();
|
||||
$this->objectChanged();
|
||||
@@ -78,6 +82,6 @@ class selfserve_wash_session_tasks_o extends db
|
||||
return $this->getFieldsWhere([
|
||||
'session_id' => $sessionId,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'task_id', 'task_text', 'description', 'services', 'buttons']);
|
||||
], ['id', 'task_id', 'task_text', 'description', 'services', 'buttons', 'dynamic_images_vehicle_type']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,12 @@ class selfserve_wash_sessions_o extends db
|
||||
$this->status->set(selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value);
|
||||
}
|
||||
|
||||
public function markRelayDisabled(): void
|
||||
{
|
||||
$this->machine_relay_enabled->set(false);
|
||||
$this->machine_relay_enabled_at->set(null);
|
||||
}
|
||||
|
||||
public function markMachineStartTriggered(): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
@@ -176,12 +176,13 @@ class birdVoiceWebhooksRoute
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->callGateToOpen($gate)) {
|
||||
try {
|
||||
$gate->openGate();
|
||||
$this->say($client, $ws, $ch, $callId, 'Aabner port: ' . $gate->name->value(), true);
|
||||
return;
|
||||
} catch (\Throwable) {
|
||||
$this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relaeet for ' . $gate->name->value() . '. Kontakt venligst support.', true);
|
||||
}
|
||||
|
||||
$this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relaeet for ' . $gate->name->value() . '. Kontakt venligst support.', true);
|
||||
}
|
||||
|
||||
protected function getCallerDepartments(int $countryCode, int $phone): array
|
||||
@@ -440,43 +441,6 @@ class birdVoiceWebhooksRoute
|
||||
}
|
||||
}
|
||||
|
||||
public function callGateToOpen(department_gates_o $gate): bool
|
||||
{
|
||||
$config = (array)$gate->config->value();
|
||||
|
||||
if (!isset($config['type']) || $config['type'] !== 'PHONE_CALL') {
|
||||
return false;
|
||||
}
|
||||
if (!isset($config['phone_number'])) {
|
||||
return false;
|
||||
}
|
||||
$phoneConfig = $config['phone_number'];
|
||||
$normalized = $this->normalizePhoneCandidate($phoneConfig);
|
||||
if ($normalized === null) {
|
||||
return false;
|
||||
}
|
||||
[$countryCode, $phone] = $normalized;
|
||||
|
||||
// Use the configured threshold or default to 10 seconds.
|
||||
$timeout = (int)($config['call_duration_threshold'] ?? 10);
|
||||
|
||||
// Use bird voice call to call the phone number, and then hang up after it is accepted to trigger the gate.
|
||||
$client = new bird();
|
||||
try {
|
||||
$client->callGateAndHangupWhenAccepted(
|
||||
(int)$countryCode,
|
||||
(int)$phone,
|
||||
$timeout,
|
||||
);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
// If the call fails, log the error and return false
|
||||
$slack = new Slack();
|
||||
$slack->send_message('Failed to call gate for phone ' . $countryCode . $phone . ': ' . $e->getMessage(), 'Bird Voice Call Webhooks');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function extractCallerPhone(array $payload): ?array
|
||||
{
|
||||
$candidates = [
|
||||
@@ -486,7 +450,7 @@ class birdVoiceWebhooksRoute
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$normalized = $this->normalizePhoneCandidate($candidate);
|
||||
$normalized = department_gates_o::normalizePhoneCandidate($candidate);
|
||||
if ($normalized !== null) {
|
||||
return $normalized;
|
||||
}
|
||||
@@ -495,61 +459,6 @@ class birdVoiceWebhooksRoute
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function normalizePhoneCandidate(mixed $candidate): ?array
|
||||
{
|
||||
if (is_array($candidate)) {
|
||||
// Determine the country code from the phone number if possible, otherwise default to 45 (Denmark)
|
||||
$phone = $candidate['phone_number'] ?? null; // E.g. +4512345678
|
||||
// If the phone number starts with a + followed by the country code and then the local phone number, we can extract the country code and local phone number
|
||||
|
||||
if ($phone !== null) {
|
||||
$country = $this->extractCountryCodeFromPhoneNumber($phone);
|
||||
return $this->normalizePhone((string)$phone, $country);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($candidate) && trim($candidate) !== '') {
|
||||
return $this->normalizePhone($candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function normalizePhone(string $raw, ?int $defaultCountryCode = 45): ?array
|
||||
{
|
||||
$trimmed = trim($raw);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
$digits = preg_replace('/\D+/', '', $trimmed);
|
||||
if (!is_string($digits) || $digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$country = $defaultCountryCode;
|
||||
$phone = $digits;
|
||||
|
||||
if (str_starts_with($trimmed, '+')) {
|
||||
$extractedCountry = $this->extractCountryCodeFromPhoneNumber($trimmed);
|
||||
if ($extractedCountry !== null) {
|
||||
$country = $extractedCountry;
|
||||
$phone = substr($digits, strlen((string)$extractedCountry));
|
||||
} elseif (strlen($digits) > 8) {
|
||||
$country = (int)substr($digits, 0, 2);
|
||||
$phone = substr($digits, 2);
|
||||
}
|
||||
} elseif ($country !== null && str_starts_with($digits, (string)$country) && strlen($digits) > 8) {
|
||||
$phone = substr($digits, strlen((string)$country));
|
||||
}
|
||||
|
||||
if ($country === null || $country <= 0 || $phone === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$country, (int)$phone];
|
||||
}
|
||||
|
||||
protected function isRegisteredCaller(int $countryCode, int $phone): bool
|
||||
{
|
||||
$userRows = (new users_o())->getFieldsWhere([
|
||||
@@ -589,29 +498,4 @@ class birdVoiceWebhooksRoute
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function extractCountryCodeFromPhoneNumber(string $phone): ?int
|
||||
{
|
||||
if (str_starts_with($phone, '+45')) {
|
||||
return 45;
|
||||
}
|
||||
if (str_starts_with($phone, '+46')) {
|
||||
return 46;
|
||||
}
|
||||
if (str_starts_with($phone, '+47')) {
|
||||
return 47;
|
||||
}
|
||||
if (str_starts_with($phone, '+358')) {
|
||||
return 358;
|
||||
}
|
||||
if (str_starts_with($phone, '+49')) {
|
||||
return 49;
|
||||
}
|
||||
if (str_starts_with($phone, '+44')) {
|
||||
return 44;
|
||||
}
|
||||
if (str_starts_with($phone, '+1')) {
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,14 @@ class departmentLanesRoute
|
||||
}
|
||||
}
|
||||
|
||||
// Thumb position (normalized)
|
||||
$thumb_position = null;
|
||||
if ($response->isRequestParameterSet('thumb_position')) {
|
||||
$thumb_position = (int)$response->getRequestParameter('thumb_position');
|
||||
self::requireMinValue($thumb_position, 1);
|
||||
self::requireMaxValue($thumb_position, 12);
|
||||
}
|
||||
|
||||
// Cache check
|
||||
$cacheKey = null;
|
||||
if (defined('redis')) {
|
||||
@@ -170,9 +178,10 @@ class departmentLanesRoute
|
||||
'buttons' => $buttons,
|
||||
'current_step' => $current_step,
|
||||
'only_current_step' => (bool)$only_current_step,
|
||||
'vehicle_type' => $vehicle_type
|
||||
'vehicle_type' => $vehicle_type,
|
||||
'thumb_position' => $thumb_position,
|
||||
];
|
||||
$cacheKey = 'dynamic_image:' . md5(json_encode($cacheParams));
|
||||
$cacheKey = 'dynamic_image_v2:' . md5(json_encode($cacheParams));
|
||||
$cachedImage = redis->get($cacheKey);
|
||||
if ($cachedImage) {
|
||||
header('Content-Type: image/png');
|
||||
@@ -186,6 +195,11 @@ class departmentLanesRoute
|
||||
switch ($dynamic_image_id) {
|
||||
case 1:
|
||||
$image = new machine_1();
|
||||
// Require thumb_position for machine_1
|
||||
if ($thumb_position === null) {
|
||||
$response->error('thumb_position parameter is required for this dynamic image', 400);
|
||||
}
|
||||
$image->thumb_position = $thumb_position;
|
||||
break;
|
||||
default:
|
||||
$response->error('Unsupported dynamic image id: ' . $dynamic_image_id, 400);
|
||||
|
||||
@@ -290,6 +290,12 @@ class moduleSelfServeRoute
|
||||
case selfserve_lane_command::RESET:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_reset');
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_open_property_access_gate');
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_open_property_exit_gate');
|
||||
break;
|
||||
}
|
||||
// Execute the command
|
||||
try {
|
||||
@@ -320,6 +326,8 @@ class moduleSelfServeRoute
|
||||
'modules_selfserve_lane_command_execute_reserve' => 'Execute self-serve lane RESERVE command',
|
||||
'modules_selfserve_lane_command_execute_release' => 'Execute self-serve lane RELEASE command (Release the lane reservation and reset its state)',
|
||||
'modules_selfserve_lane_command_execute_reset' => 'Execute self-serve lane RESET command',
|
||||
'modules_selfserve_lane_command_execute_open_property_access_gate' => 'Execute self-serve lane OPEN_PROPERTY_ACCESS_GATE command',
|
||||
'modules_selfserve_lane_command_execute_open_property_exit_gate' => 'Execute self-serve lane OPEN_PROPERTY_EXIT_GATE command',
|
||||
'modules_selfserve_lane_command_bypass_customer_number_validation' => 'Bypass customer number validation when executing commands',
|
||||
]
|
||||
);
|
||||
@@ -368,8 +376,12 @@ class moduleSelfServeRoute
|
||||
}
|
||||
}
|
||||
// Persist on lane cache (overwrites previous allowed services)
|
||||
$lane->setLaneCache($lane_id, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $allowed_services);
|
||||
$response->success(['lane_id' => $lane_id, 'allowed_services' => $allowed_services]);
|
||||
$relay_sync = $lane->syncMachineRelayFromVisibleServices($allowed_services, true);
|
||||
$response->success([
|
||||
'lane_id' => $lane_id,
|
||||
'allowed_services' => $allowed_services,
|
||||
'relay_sync' => $relay_sync,
|
||||
]);
|
||||
}, [
|
||||
'modules_selfserve_lane_services_set_allowed' => 'Set allowed services for a lane based on currently shown tasks (post-Q&A)'
|
||||
]);
|
||||
@@ -582,9 +594,19 @@ class moduleSelfServeRoute
|
||||
$lane->setMachineRelayStatus((bool)$on);
|
||||
// Keep lane cache state aligned with the latest explicit relay action
|
||||
try {
|
||||
$machine_relay_id = '';
|
||||
if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
|
||||
$machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
|
||||
}
|
||||
$is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
|
||||
$lane->setLaneState((bool)$on
|
||||
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON
|
||||
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF);
|
||||
? ($is_demo_machine_relay
|
||||
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED
|
||||
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON)
|
||||
: ($is_demo_machine_relay
|
||||
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED
|
||||
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF)
|
||||
);
|
||||
} catch (\Throwable $ignored) {}
|
||||
|
||||
$status = $lane->getMachineRelayStatus();
|
||||
@@ -672,7 +694,7 @@ class moduleSelfServeRoute
|
||||
}
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE);
|
||||
// A started wash should also turn on cleaner when configured.
|
||||
try {
|
||||
if (
|
||||
@@ -834,7 +856,18 @@ class moduleSelfServeRoute
|
||||
// Force enable the machine relay (bypass gating)
|
||||
$lane->forceTurnOnMachineRelay($duration);
|
||||
// Reflect relay state explicitly
|
||||
try { $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON); } catch (\Throwable $ignored) {}
|
||||
try {
|
||||
$machine_relay_id = '';
|
||||
if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
|
||||
$machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
|
||||
}
|
||||
$is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
|
||||
$lane->setLaneState(
|
||||
$is_demo_machine_relay
|
||||
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED
|
||||
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON
|
||||
);
|
||||
} catch (\Throwable $ignored) {}
|
||||
$response->success([
|
||||
'lane_id' => $lane_id,
|
||||
'forced' => true,
|
||||
@@ -890,7 +923,18 @@ class moduleSelfServeRoute
|
||||
// Turn off the machine relay (do not swallow errors)
|
||||
$lane->forceTurnOffMachineRelay();
|
||||
// Reflect relay state explicitly
|
||||
try { $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF); } catch (\Throwable $ignored) {}
|
||||
try {
|
||||
$machine_relay_id = '';
|
||||
if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
|
||||
$machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
|
||||
}
|
||||
$is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
|
||||
$lane->setLaneState(
|
||||
$is_demo_machine_relay
|
||||
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED
|
||||
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF
|
||||
);
|
||||
} catch (\Throwable $ignored) {}
|
||||
$response->success([
|
||||
'lane_id' => $lane_id,
|
||||
'forced' => true,
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
it('guards pagination against searchable fields missing from legacy schemas', function (): void {
|
||||
$traitContent = file_get_contents(app_path('traits/db_object_t.php'));
|
||||
|
||||
expect($traitContent)->not->toBeFalse();
|
||||
expect($traitContent)->toContain('$fields = array_values(array_intersect($fields, $tmpFields));');
|
||||
expect($traitContent)->toContain("if (in_array('deleted_at', \$tmpFields, true))");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
app_require('modules/selfserve/helpers/selfserve_lane_command.php');
|
||||
|
||||
use modules\selfserve\helpers\selfserve_lane_command;
|
||||
|
||||
it('parses property gate lane commands', function (): void {
|
||||
expect(selfserve_lane_command::tryFrom('OPEN_PROPERTY_ACCESS_GATE'))
|
||||
->toBe(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE);
|
||||
expect(selfserve_lane_command::tryFrom('OPEN_PROPERTY_EXIT_GATE'))
|
||||
->toBe(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE);
|
||||
expect(selfserve_lane_command::tryFrom('open_property_exit_gate'))
|
||||
->toBe(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
app_require('modules/selfserve/traits/selfserve_lane_port_controller_t.php');
|
||||
app_require('modules/selfserve/helpers/selfserve_lane_port.php');
|
||||
app_require('modules/selfserve/helpers/selfserve_lane_state.php');
|
||||
app_require('modules/selfserve/helpers/selfserve_lane_status.php');
|
||||
app_require('modules/shelly/helpers/shelly_device_switch.php');
|
||||
|
||||
use modules\selfserve\helpers\selfserve_lane_port;
|
||||
use modules\selfserve\helpers\selfserve_lane_state;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use modules\selfserve\traits\selfserve_lane_port_controller_t;
|
||||
use modules\shelly\helpers\shelly_device_switch;
|
||||
|
||||
@@ -52,17 +56,33 @@ class SelfserveLanePortControllerHarness
|
||||
public int $id = 55;
|
||||
public object $department_lane;
|
||||
public SelfserveLanePortSwitchFake $switchFake;
|
||||
public selfserve_lane_status $laneStatus;
|
||||
public selfserve_lane_state $laneState;
|
||||
|
||||
public function __construct(string $relayInId, string $relayOutId)
|
||||
{
|
||||
$this->department_lane = new SelfserveLanePortDepartmentLaneFake($relayInId, $relayOutId);
|
||||
$this->switchFake = new SelfserveLanePortSwitchFake();
|
||||
$this->laneStatus = selfserve_lane_status::AVAILABLE;
|
||||
$this->laneState = selfserve_lane_state::IDLE;
|
||||
}
|
||||
|
||||
protected function createShellySwitchDevice(): shelly_device_switch
|
||||
{
|
||||
return $this->switchFake;
|
||||
}
|
||||
|
||||
public function getLaneStatus(): selfserve_lane_status
|
||||
{
|
||||
return $this->laneStatus;
|
||||
}
|
||||
|
||||
public function setLaneState(selfserve_lane_state $state): void
|
||||
{
|
||||
$this->laneState = $state;
|
||||
}
|
||||
|
||||
public function logLaneAction(...$args): void {}
|
||||
}
|
||||
|
||||
it('opens exit port by switching relay_in_id on', function (): void {
|
||||
@@ -98,3 +118,16 @@ it('opens entrance port by switching relay_out_id on', function (): void {
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps demo relay gate-open queued but skips Shelly switch calls', function (): void {
|
||||
$lane = new SelfserveLanePortControllerHarness(
|
||||
relayInId: 'demo-relay-in',
|
||||
relayOutId: 'demo-relay-out'
|
||||
);
|
||||
|
||||
$result = $lane->open(selfserve_lane_port::EXIT);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
expect($lane->laneState)->toBe(selfserve_lane_state::EXIT_PORT_OPEN_QUEUED);
|
||||
expect($lane->switchFake->switchCalls)->toBe([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
app_require('objects/department_gates_o.php');
|
||||
app_require('modules/selfserve/traits/selfserve_lane_command_t.php');
|
||||
app_require('modules/selfserve/helpers/selfserve_lane_command.php');
|
||||
app_require('modules/selfserve/classes/selfserve_lane_command_arguments.php');
|
||||
|
||||
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
||||
use modules\selfserve\helpers\selfserve_lane_command;
|
||||
use modules\selfserve\traits\selfserve_lane_command_t;
|
||||
use objects\department_gates_o;
|
||||
|
||||
class SelfserveLanePropertyGateValueFake
|
||||
{
|
||||
public function __construct(private readonly int $value) {}
|
||||
|
||||
public function value(): int
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
|
||||
class SelfserveLanePropertyGateDepartmentLaneFake
|
||||
{
|
||||
public SelfserveLanePropertyGateValueFake $department;
|
||||
|
||||
public function __construct(int $departmentId)
|
||||
{
|
||||
$this->department = new SelfserveLanePropertyGateValueFake($departmentId);
|
||||
}
|
||||
}
|
||||
|
||||
class SelfserveLanePropertyGateFake extends department_gates_o
|
||||
{
|
||||
public int $openCalls = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly bool $existsFlag = true,
|
||||
private readonly bool $throwOnOpen = false,
|
||||
private readonly string $throwMessage = 'gate open failed',
|
||||
) {}
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->existsFlag;
|
||||
}
|
||||
|
||||
public function openGate(): void
|
||||
{
|
||||
$this->openCalls++;
|
||||
if ($this->throwOnOpen) {
|
||||
throw new \RuntimeException($this->throwMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SelfserveLanePropertyGateHarness
|
||||
{
|
||||
use selfserve_lane_command_t;
|
||||
|
||||
public const DEFAULT_WASH_START_TIME = 0;
|
||||
public const DEFAULT_CUSTOMER_NUMBER = 0;
|
||||
public const DEFAULT_LICENSE_PLATE = '';
|
||||
|
||||
public int $id = 901;
|
||||
public object $department_lane;
|
||||
public bool $selfServeEnabled = true;
|
||||
public ?department_gates_o $entranceGate = null;
|
||||
public ?department_gates_o $exitGate = null;
|
||||
|
||||
public function __construct(int $departmentId = 77)
|
||||
{
|
||||
$this->department_lane = new SelfserveLanePropertyGateDepartmentLaneFake($departmentId);
|
||||
}
|
||||
|
||||
protected function isDepartmentSelfServeEnabled(): bool
|
||||
{
|
||||
return $this->selfServeEnabled;
|
||||
}
|
||||
|
||||
protected function resolveDepartmentGateForCommand(int $department_id, bool $isAccessGate): ?department_gates_o
|
||||
{
|
||||
return $isAccessGate ? $this->entranceGate : $this->exitGate;
|
||||
}
|
||||
}
|
||||
|
||||
it('opens entrance gate for OPEN_PROPERTY_ACCESS_GATE command', function (): void {
|
||||
$lane = new SelfserveLanePropertyGateHarness();
|
||||
$gate = new SelfserveLanePropertyGateFake();
|
||||
$lane->entranceGate = $gate;
|
||||
|
||||
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments());
|
||||
|
||||
expect($gate->openCalls)->toBe(1);
|
||||
});
|
||||
|
||||
it('opens exit gate for OPEN_PROPERTY_EXIT_GATE command', function (): void {
|
||||
$lane = new SelfserveLanePropertyGateHarness();
|
||||
$gate = new SelfserveLanePropertyGateFake();
|
||||
$lane->exitGate = $gate;
|
||||
|
||||
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments());
|
||||
|
||||
expect($gate->openCalls)->toBe(1);
|
||||
});
|
||||
|
||||
it('blocks property gate commands when department self-serve is disabled', function (): void {
|
||||
$lane = new SelfserveLanePropertyGateHarness();
|
||||
$lane->selfServeEnabled = false;
|
||||
$lane->entranceGate = new SelfserveLanePropertyGateFake();
|
||||
|
||||
expect(function () use ($lane): void {
|
||||
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments());
|
||||
})->toThrow(\RuntimeException::class, 'Self-serve is not enabled');
|
||||
});
|
||||
|
||||
it('throws a clear error when entrance gate is missing for property access command', function (): void {
|
||||
$lane = new SelfserveLanePropertyGateHarness();
|
||||
$lane->entranceGate = null;
|
||||
|
||||
expect(function () use ($lane): void {
|
||||
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments());
|
||||
})->toThrow(\RuntimeException::class, 'No entrance gate configured');
|
||||
});
|
||||
|
||||
it('wraps low-level gate errors for property access command failures', function (): void {
|
||||
$lane = new SelfserveLanePropertyGateHarness();
|
||||
$lane->entranceGate = new SelfserveLanePropertyGateFake(
|
||||
existsFlag: true,
|
||||
throwOnOpen: true,
|
||||
throwMessage: 'simulated gateway timeout',
|
||||
);
|
||||
|
||||
expect(function () use ($lane): void {
|
||||
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments());
|
||||
})->toThrow(\RuntimeException::class, 'Failed to open property access gate: simulated gateway timeout');
|
||||
});
|
||||
|
||||
@@ -162,6 +162,8 @@ class SelfserveLaneRelayControllerHarness
|
||||
public array $sleepCalls = [];
|
||||
/** @var array<int,array{endpoint:string,payload:array,time:float}> */
|
||||
public array $shellyCalls = [];
|
||||
/** @var array<string,mixed> */
|
||||
public array $laneCache = [];
|
||||
/** @var array<string,array<int,array|object|null>> */
|
||||
private array $queuedResponses = [];
|
||||
private selfserve_lane_status $laneStatus;
|
||||
@@ -205,7 +207,13 @@ class SelfserveLaneRelayControllerHarness
|
||||
|
||||
public function getLaneCache(int $lane_id, string $key): mixed
|
||||
{
|
||||
return [];
|
||||
return $this->laneCache[$key . '_' . $lane_id] ?? null;
|
||||
}
|
||||
|
||||
public function setLaneCache(int $lane_id, string $key, mixed $value): self
|
||||
{
|
||||
$this->laneCache[$key . '_' . $lane_id] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function sendShellyPost(string $endpoint, array $payload): array|object|null
|
||||
@@ -402,3 +410,132 @@ it('supports hard relay set even when lane status is CLOSED', function (): void
|
||||
expect($result)->toBeTrue();
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
|
||||
});
|
||||
|
||||
it('enables MACHINE relay when MACHINE is visible in allowed services', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
|
||||
$result = $harness->syncMachineRelayFromVisibleServices(['machine'], true);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'machine_visible' => true,
|
||||
'relay_action' => 'enabled',
|
||||
'relay_target_on' => true,
|
||||
]);
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
|
||||
expect($harness->shellyCalls[0]['payload'])->toMatchArray([
|
||||
'id' => 'relay-machine',
|
||||
'channel' => 0,
|
||||
'on' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('turns MACHINE relay off immediately when MACHINE is not visible', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
|
||||
$result = $harness->syncMachineRelayFromVisibleServices(['MACHINE_CLEANER'], true);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'machine_visible' => false,
|
||||
'relay_action' => 'disabled',
|
||||
'relay_target_on' => false,
|
||||
]);
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
|
||||
expect($harness->shellyCalls[0]['payload'])->toMatchArray([
|
||||
'id' => 'relay-machine',
|
||||
'channel' => 0,
|
||||
'on' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not enable MACHINE relay when allowEnable is false even if MACHINE is visible', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
|
||||
$result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], false);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'machine_visible' => true,
|
||||
'relay_action' => 'noop_enable_blocked',
|
||||
'relay_target_on' => true,
|
||||
]);
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
|
||||
});
|
||||
|
||||
it('is a safe no-op when MACHINE relay is not configured', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
|
||||
machineRelayId: '',
|
||||
programPickerRelayId: 'relay-program',
|
||||
cleanerRelayId: 'relay-cleaner'
|
||||
);
|
||||
|
||||
$result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], true);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'machine_visible' => true,
|
||||
'relay_action' => 'noop_missing_machine_relay',
|
||||
'relay_target_on' => true,
|
||||
]);
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
|
||||
});
|
||||
|
||||
it('uses local demo responses and skips Shelly cloud calls for demo relay ids', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
|
||||
machineRelayId: 'demo-machine',
|
||||
programPickerRelayId: 'demo-program',
|
||||
cleanerRelayId: 'demo-cleaner'
|
||||
);
|
||||
|
||||
$result = $harness->setMachineRelayStatus(true);
|
||||
$status = $harness->getMachineRelayStatus();
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
expect($status)->toMatchArray([
|
||||
'relay_id' => 'demo-machine',
|
||||
'online' => true,
|
||||
'on' => true,
|
||||
]);
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0);
|
||||
});
|
||||
|
||||
it('returns default OFF status for demo relay ids without Shelly lookups', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
|
||||
machineRelayId: 'demo-machine',
|
||||
programPickerRelayId: 'demo-program',
|
||||
cleanerRelayId: 'demo-cleaner'
|
||||
);
|
||||
|
||||
$status = $harness->getMachineRelayStatus();
|
||||
|
||||
expect($status)->toMatchArray([
|
||||
'relay_id' => 'demo-machine',
|
||||
'online' => true,
|
||||
'on' => false,
|
||||
]);
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0);
|
||||
});
|
||||
|
||||
it('excludes demo relay ids from Shelly status batch payloads', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
|
||||
machineRelayId: 'demo-machine',
|
||||
programPickerRelayId: 'relay-program',
|
||||
cleanerRelayId: 'demo-cleaner'
|
||||
);
|
||||
$harness->queueShellyResponse('/v2/devices/api/get', [
|
||||
[
|
||||
'id' => 'relay-program',
|
||||
'online' => true,
|
||||
'status' => ['switch:0' => ['output' => true]],
|
||||
],
|
||||
]);
|
||||
|
||||
$status = $harness->getMachineProgramPickerRelayStatus();
|
||||
|
||||
expect($status['relay_id'])->toBe('relay-program');
|
||||
expect($status['on'])->toBeTrue();
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1);
|
||||
expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-program']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
it('synchronizes machine relay from visible services during session synchronization', function (): void {
|
||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||
|
||||
expect($washFlow)->not->toBeFalse();
|
||||
expect($washFlow)->toContain('$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);');
|
||||
expect($washFlow)->toContain('protected function syncMachineRelayFromVisibleServices');
|
||||
expect($washFlow)->toContain('$lane->syncMachineRelayFromVisibleServices(');
|
||||
expect($washFlow)->toContain('$session->markRelayDisabled();');
|
||||
expect($washFlow)->toContain('$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));');
|
||||
});
|
||||
|
||||
it('adds explicit relay disable session helper for visibility-driven OFF transitions', function (): void {
|
||||
$sessionsObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php'));
|
||||
|
||||
expect($sessionsObject)->not->toBeFalse();
|
||||
expect($sessionsObject)->toContain('public function markRelayDisabled(): void');
|
||||
expect($sessionsObject)->toContain('$this->machine_relay_enabled->set(false);');
|
||||
expect($sessionsObject)->toContain('$this->machine_relay_enabled_at->set(null);');
|
||||
});
|
||||
|
||||
@@ -78,6 +78,15 @@ it('wires machine relay status get and set endpoints', function (): void {
|
||||
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatusHard(true)');
|
||||
});
|
||||
|
||||
it('wires allowed services route through machine relay visibility sync', function (): void {
|
||||
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||
|
||||
expect($moduleSelfServeRoute)->not->toBeFalse();
|
||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/services/allowed');
|
||||
expect($moduleSelfServeRoute)->toContain('syncMachineRelayFromVisibleServices($allowed_services, true)');
|
||||
expect($moduleSelfServeRoute)->toContain("'relay_sync' => \$relay_sync");
|
||||
});
|
||||
|
||||
it('wires self-serve lane gate open endpoint', function (): void {
|
||||
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||
|
||||
@@ -89,6 +98,16 @@ it('wires self-serve lane gate open endpoint', function (): void {
|
||||
expect($moduleSelfServeRoute)->toContain('$lane->open($gate)');
|
||||
});
|
||||
|
||||
it('wires self-serve property gate command permissions', function (): void {
|
||||
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||
|
||||
expect($moduleSelfServeRoute)->not->toBeFalse();
|
||||
expect($moduleSelfServeRoute)->toContain('case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE');
|
||||
expect($moduleSelfServeRoute)->toContain('case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE');
|
||||
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_access_gate');
|
||||
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_exit_gate');
|
||||
});
|
||||
|
||||
it('wires in-progress self-serve wash details endpoint', function (): void {
|
||||
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
it('reads machine allowed state from session summary payload safely', function (): void {
|
||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||
|
||||
expect($washFlow)->not->toBeFalse();
|
||||
|
||||
$methodOffset = strpos($washFlow, 'public function isMachineAllowedToStartWash');
|
||||
expect($methodOffset)->not->toBeFalse();
|
||||
|
||||
$methodBody = substr($washFlow, (int)$methodOffset, 300);
|
||||
expect($methodBody)->toContain("return (\$sessionSummary['session']['allowed'] ?? false) === true;");
|
||||
expect($methodBody)->not->toContain("return \$sessionSummary['allowed'] === true;");
|
||||
});
|
||||
|
||||
@@ -523,6 +523,12 @@ trait db_object_t
|
||||
|
||||
if (empty($fields)) {
|
||||
$fields = $tmpFields;
|
||||
} else {
|
||||
// Keep only columns that actually exist on the base table to avoid SQL errors on legacy schemas.
|
||||
$fields = array_values(array_intersect($fields, $tmpFields));
|
||||
if (empty($fields)) {
|
||||
$fields = $tmpFields;
|
||||
}
|
||||
}
|
||||
|
||||
$whereClauses = [];
|
||||
@@ -684,7 +690,7 @@ trait db_object_t
|
||||
}
|
||||
|
||||
// Check for "deleted_at" column
|
||||
if (in_array('deleted_at', $fields)) {
|
||||
if (in_array('deleted_at', $tmpFields, true)) {
|
||||
$whereClauses[] = "`deleted_at` IS NULL";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user