Remove outdated edge gateway object classes, add new agent implementation

Transitioned from obsolete gateway object classes (`edge_gateway_shell_action_jobs_o`, `edge_gateway_shell_events_o`, `edge_gateway_shell_sessions_o`, `edge_gateway_update_jobs_o`) to the new agent implementation (`edge-gateway-agent/agent.php`).
This commit is contained in:
Jeppe Bundgaard
2026-04-21 14:13:17 +02:00
parent d30a006457
commit 7d450e285e
90 changed files with 11811 additions and 2760 deletions
@@ -0,0 +1,16 @@
FROM php:8.2-cli
WORKDIR /opt/truckwash-edge-agent
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
ca-certificates; \
docker-php-ext-install curl; \
rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/resources/edge-gateway-agent/ ./
ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"]
CMD ["--config", "/config/test-gateway.json"]
+9 -34
View File
@@ -137,7 +137,6 @@ function buildTransportHeartbeatState(brokerState = {}) {
status: "ONLINE",
metadata: {
command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
shell_transport: "API_POLLING",
broker_connected: brokerConnected,
broker_url: brokerState.url || null,
broker_last_error: brokerState.lastError || null,
@@ -1763,20 +1762,21 @@ export async function startAgent({
config.configPath = configPath;
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds || 20);
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs || 1000);
const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS);
let stopped = false;
let cpuSnapshot = null;
let lastHeartbeatLatencyMs = null;
const shellEventPublisher = createShellEventPublisher(config, fetchImpl);
void createShellBridgeImpl;
let brokerBridge = null;
const shell = createShellBridgeImpl((message) => {
shellEventPublisher.publish(message);
brokerBridge?.send(message);
});
const shell = {
open: async () => {},
input: () => {},
resize: () => {},
close: () => {},
dispose: () => {},
};
brokerBridge = createBrokerBridge({
config,
shell,
@@ -1841,32 +1841,8 @@ export async function startAgent({
}
};
const runShellActionPollLoop = async () => {
while (!stopped) {
try {
const action = await pollShellActionJob(config, fetchImpl, shellActionPollTimeoutSeconds);
if (stopped) {
break;
}
if (!action) {
continue;
}
await processPolledShellAction(config, action, shell, fetchImpl);
} catch {
if (stopped) {
break;
}
await new Promise((resolve) => setTimeout(resolve, shellActionPollRetryDelayMs));
}
}
};
await sendTransportHeartbeat();
const commandPollPromise = runCommandPollLoop();
const shellActionPollPromise = runShellActionPollLoop();
const timer = setInterval(() => {
sendTransportHeartbeat().catch(() => {});
@@ -1878,8 +1854,7 @@ export async function startAgent({
clearInterval(timer);
brokerBridge?.stop();
shell.dispose();
await shellEventPublisher.drain().catch(() => {});
await Promise.allSettled([commandPollPromise, shellActionPollPromise]);
await Promise.allSettled([commandPollPromise]);
};
return {
File diff suppressed because it is too large Load Diff
+33
View File
@@ -29,6 +29,8 @@ use interfaces\economic_i;
class economic implements economic_i
{
public const DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
/**
* Configuration of the economic module
* @var economic_c
@@ -120,6 +122,37 @@ class economic implements economic_i
return new $this->helpers->economic_tasks();
}
public function getTransactionDraftCustomerNumber(): ?int
{
$value = $this->config->transaction_draft_customer_number->getVariableValue();
if ($value === null || $value === '') {
return null;
}
$customer_number = (int)$value;
return $customer_number > 0 ? $customer_number : null;
}
public function isDraftCustomerNumber(?int $customer_number): bool
{
$configured_customer_number = $this->getTransactionDraftCustomerNumber();
if ($configured_customer_number === null || $customer_number === null) {
return false;
}
return $configured_customer_number === (int)$customer_number;
}
/**
* @throws \Exception
*/
public function assertCustomerNumberIsNotDraft(?int $customer_number): void
{
if ($this->isDraftCustomerNumber($customer_number)) {
throw new \Exception(self::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE);
}
}
/**
* Create a customer in e-conomic and return the raw upstream payload.
*/
@@ -23,6 +23,7 @@ class economic_transfer_executor
*/
public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array
{
$economic = new economic();
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new Exception('Order not found');
@@ -44,6 +45,7 @@ class economic_transfer_executor
if (!$customer->exists()) {
throw new Exception('Customer not found');
}
$economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
$economic_invoice_draft = new economic_invoice_draft_mo();
@@ -128,6 +130,7 @@ class economic_transfer_executor
*/
public function exportOrderInvoice(int $order_id, int $user_id = 0): array
{
$economic = new economic();
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new Exception('Order not found');
@@ -137,6 +140,7 @@ class economic_transfer_executor
if (!$customer->exists()) {
throw new Exception('Customer not found');
}
$economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
@@ -189,6 +193,7 @@ class economic_transfer_executor
{
$collected_order_invoices = (new collected_order_invoices_o())->select($collected_invoice_id);
$collected_order_invoices->requireSelected();
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
@@ -0,0 +1,145 @@
<?php
namespace classes;
use Exception;
class edge_gateway_agent_artifact_locator
{
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
/**
* @return array<int,string>
*/
public static function candidatePaths(
string $fileName,
?string $basePath = null,
?string $mountedArtifactDirectory = null,
?string $bakedArtifactDirectory = null
): array
{
$basePath = self::normalizePath($basePath ?? WD);
$candidateDirectories = [];
$configuredDirectory = trim((string)(getenv('EDGE_AGENT_ARTIFACT_DIR') ?: ''));
if ($configuredDirectory !== '') {
$candidateDirectories[] = self::normalizePath($configuredDirectory);
}
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
if ($mountedArtifactDirectory !== null) {
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
}
$bakedArtifactDirectory = $bakedArtifactDirectory ?? self::bakedArtifactDirectory();
if ($bakedArtifactDirectory !== null) {
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
}
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$paths = [];
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
$paths[] = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $fileName;
}
return array_values(array_unique($paths));
}
/**
* @throws Exception
*/
public static function resolve(
string $fileName,
?string $basePath = null,
?string $mountedArtifactDirectory = null,
?string $bakedArtifactDirectory = null
): string
{
$candidatePaths = self::candidatePaths($fileName, $basePath, $mountedArtifactDirectory, $bakedArtifactDirectory);
foreach ($candidatePaths as $path) {
if (is_file($path)) {
return $path;
}
}
$message = 'Missing edge agent artifact: ' . $fileName . '.';
$message .= ' Checked paths: ' . implode(', ', $candidatePaths) . '.';
$message .= ' Deploy the router-owned artifacts under ' . self::routerArtifactDirectory($basePath) . '.';
$legacyNodeArtifact = self::legacyNodeArtifactPath($basePath);
if ($legacyNodeArtifact !== null) {
$message .= ' Legacy Node dist artifact found at ' . $legacyNodeArtifact . '.';
$message .= ' Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.';
}
throw new Exception($message);
}
private static function normalizePath(string $path): string
{
$normalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if (DIRECTORY_SEPARATOR === '/') {
$normalized = preg_replace('#/+#', '/', $normalized) ?: $normalized;
}
$trimmed = rtrim($normalized, DIRECTORY_SEPARATOR);
if ($trimmed === '' && str_starts_with($normalized, DIRECTORY_SEPARATOR)) {
return DIRECTORY_SEPARATOR;
}
return $trimmed;
}
private static function routerArtifactDirectory(string $basePath): string
{
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
}
private static function mountedArtifactDirectory(): ?string
{
if (DIRECTORY_SEPARATOR !== '/') {
return null;
}
return self::DEFAULT_MOUNTED_ARTIFACT_DIRECTORY;
}
private static function bakedArtifactDirectory(): ?string
{
if (DIRECTORY_SEPARATOR !== '/') {
return null;
}
return self::DEFAULT_BAKED_ARTIFACT_DIRECTORY;
}
private static function legacyNodeArtifactPath(?string $basePath = null): ?string
{
$basePath = self::normalizePath($basePath ?? WD);
$candidateDirectories = [
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
];
if (DIRECTORY_SEPARATOR === '/') {
$candidateDirectories[] = '/services/edge-agent/dist';
}
foreach (array_values(array_unique(array_map(static fn(string $directory): string => self::normalizePath($directory), $candidateDirectories))) as $directory) {
$path = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'agent.mjs';
if (is_file($path)) {
return $path;
}
}
return null;
}
}
@@ -0,0 +1,72 @@
<?php
namespace classes;
use Exception;
class edge_gateway_install_service
{
private const ARTIFACTS = [
'agent.php' => 'application/x-httpd-php; charset=utf-8',
'truckwash-edge-agent.service' => 'text/plain; charset=utf-8',
];
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
public function buildInstallScript(string $plainToken): string
{
return $this->manager()->buildInstallScript($plainToken);
}
/**
* @throws Exception
*/
public function verifyInstallToken(string $plainToken): array
{
return $this->manager()->verifyInstallToken($plainToken);
}
/**
* @throws Exception
*/
public function readArtifact(string $fileName): string
{
$path = $this->artifactPath($fileName);
$contents = file_get_contents($path);
if ($contents === false) {
throw new Exception('Unable to read edge agent artifact');
}
return $contents;
}
public function contentType(string $fileName): string
{
return self::ARTIFACTS[$fileName] ?? 'application/octet-stream';
}
public function buildArtifactUrl(string $fileName): string
{
return rtrim($this->manager()->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName;
}
/**
* @throws Exception
*/
public function artifactPath(string $fileName): string
{
if (!array_key_exists($fileName, self::ARTIFACTS)) {
throw new Exception('Unknown edge agent artifact');
}
return edge_gateway_agent_artifact_locator::resolve($fileName);
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
<?php
namespace classes;
use Exception;
class edge_gateway_operation_exception extends Exception
{
public function __construct(
string $message,
public readonly string $errorCode,
public readonly int $status = 400
) {
parent::__construct($message, $status);
}
}
@@ -0,0 +1,767 @@
<?php
namespace classes;
use Exception;
use objects\edge_gateway_operation_events_o;
use objects\edge_gateway_operations_o;
use objects\edge_gateways_o;
class edge_gateway_operation_service
{
public const TYPE_DISCOVERY = 'DISCOVERY';
public const TYPE_UPDATE = 'UPDATE';
public const TYPE_UNINSTALL = 'UNINSTALL';
public const STATUS_PENDING = 'PENDING';
public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED';
public const LEVEL_INFO = 'INFO';
public const LEVEL_WARNING = 'WARNING';
public const LEVEL_ERROR = 'ERROR';
public const ERROR_OFFLINE = 'EDGE_GATEWAY_OFFLINE';
public const ERROR_STALE_HEARTBEAT = 'EDGE_GATEWAY_STALE_HEARTBEAT';
public const ERROR_INVALID_TOKEN = 'EDGE_GATEWAY_INVALID_TOKEN';
public const ERROR_OPERATION_TIMEOUT = 'EDGE_GATEWAY_OPERATION_TIMEOUT';
public const ERROR_UNSUPPORTED_VERSION = 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
public const ERROR_CONFLICT = 'EDGE_GATEWAY_CONFLICT';
public const ERROR_VALIDATION = 'EDGE_GATEWAY_VALIDATION_FAILED';
public const POLL_INTERVAL_MICROSECONDS = 250000;
public const OPERATION_TIMEOUT_SECONDS = 900;
public const OPERATION_LEASE_SECONDS = 45;
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listOperations(int $gatewayId, int $limit = 20, bool $includeEvents = true): array
{
$this->requireGateway($gatewayId);
$this->failTimedOutOperations($gatewayId);
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'deleted_at' => null,
], ['id']);
$ids = array_map(static fn(array $row): int => (int)$row['id'], $rows);
rsort($ids);
$ids = array_slice($ids, 0, max(1, $limit));
$operations = [];
foreach ($ids as $id) {
$operations[] = $this->serializeOperation((new edge_gateway_operations_o())->select($id), $includeEvents);
}
return $operations;
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listOperationEvents(int $gatewayId, int $operationId, int $limit = 100): array
{
$this->requireOperation($gatewayId, $operationId);
$rows = (new edge_gateway_operation_events_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
], ['id']);
$ids = array_map(static fn(array $row): int => (int)$row['id'], $rows);
sort($ids);
$ids = array_slice($ids, max(0, count($ids) - max(1, $limit)));
$events = [];
foreach ($ids as $id) {
$events[] = (new edge_gateway_operation_events_o())->select($id)->asArray();
}
return $events;
}
/**
* @throws Exception
*/
public function getActiveOperation(int $gatewayId, bool $includeEvents = true): ?array
{
$this->requireGateway($gatewayId);
$this->failTimedOutOperations($gatewayId);
$statement = db::getPDO()->prepare(
"SELECT id
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND status IN ('PENDING', 'IN_PROGRESS')
ORDER BY FIELD(status, 'IN_PROGRESS', 'PENDING'), id ASC
LIMIT 1"
);
$statement->execute([':gateway_id' => $gatewayId]);
$row = $statement->fetch();
if (!is_array($row) || empty($row['id'])) {
return null;
}
return $this->serializeOperation((new edge_gateway_operations_o())->select((int)$row['id']), $includeEvents);
}
/**
* @throws Exception
*/
public function buildRecentOperationsSummary(int $gatewayId): array
{
$operations = $this->listOperations($gatewayId, 25, false);
$summary = [
'total' => count($operations),
'pending' => 0,
'in_progress' => 0,
'completed' => 0,
'failed' => 0,
'latest_completed_at' => null,
'latest_failed_at' => null,
'latest_type' => $operations[0]['type'] ?? null,
'latest_status' => $operations[0]['status'] ?? null,
];
foreach ($operations as $operation) {
$status = (string)($operation['status'] ?? self::STATUS_PENDING);
if ($status === self::STATUS_PENDING) {
$summary['pending'] += 1;
} elseif ($status === self::STATUS_IN_PROGRESS) {
$summary['in_progress'] += 1;
} elseif ($status === self::STATUS_COMPLETED) {
$summary['completed'] += 1;
$summary['latest_completed_at'] ??= $operation['completed_at'] ?? null;
} elseif ($status === self::STATUS_FAILED) {
$summary['failed'] += 1;
$summary['latest_failed_at'] ??= $operation['completed_at'] ?? null;
}
}
return $summary;
}
/**
* @throws Exception
*/
public function queueOperation(int $gatewayId, string $type, array $request = [], ?int $requestedBy = null): array
{
$gateway = $this->requireGateway($gatewayId);
$type = self::normalizeOperationType($type);
$request = $this->validateOperationRequest($gateway, $type, $request);
if ($this->getActiveOperation($gatewayId) !== null) {
throw new edge_gateway_operation_exception(
'Another gateway operation is already active',
self::ERROR_CONFLICT,
409
);
}
if ($type === self::TYPE_DISCOVERY) {
$gateway->discovery_status->set('PENDING');
} elseif ($type === self::TYPE_UPDATE) {
$targetVersion = trim((string)($request['target_version'] ?? ''));
if ($targetVersion !== '') {
$gateway->target_version->set($targetVersion);
}
}
$summary = [
'label' => match ($type) {
self::TYPE_DISCOVERY => 'Discovery queued',
self::TYPE_UPDATE => 'Update queued',
self::TYPE_UNINSTALL => 'Uninstall queued',
},
'progress' => 0,
'retryable' => true,
];
$operationId = (new edge_gateway_operations_o())->add_object([
'gateway_id' => $gatewayId,
'type' => $type,
'operation_type' => $type,
'status' => self::STATUS_PENDING,
'request_json' => $request,
'summary_json' => $summary,
'result_json' => [],
'error_code' => null,
'error_message' => null,
'correlation_id' => bin2hex(random_bytes(16)),
'agent_instance_id' => null,
'lease_expires_at' => null,
'last_progress_at' => null,
'attempt_count' => 0,
'requested_by' => $requestedBy,
'requested_at' => $this->now(),
'started_at' => null,
'completed_at' => null,
]);
$this->appendEventRecord(
$gatewayId,
$operationId,
self::LEVEL_INFO,
'OPERATION_QUEUED',
'Operation queued for gateway execution',
['type' => $type, 'request' => $request]
);
$this->manager()->logGatewayAudit(
$gatewayId,
(int)$gateway->department_id->value(),
'GATEWAY_OPERATION_QUEUED',
$requestedBy,
['operation_id' => $operationId, 'type' => $type]
);
return $this->serializeOperation((new edge_gateway_operations_o())->select($operationId), true);
}
/**
* @throws Exception
*/
public function queueDiscoveryOperation(int $gatewayId, ?int $requestedBy = null): array
{
return $this->queueOperation($gatewayId, self::TYPE_DISCOVERY, [], $requestedBy);
}
/**
* @throws Exception
*/
public function rotateCredentials(int $gatewayId, ?int $userId = null): array
{
return $this->manager()->rotateGatewayCredentials($gatewayId, $userId);
}
/**
* @throws Exception
*/
public function claimNextOperation(
int $gatewayId,
string $plainToken,
int $waitSeconds = edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS,
?string $agentInstanceId = null
): ?array {
try {
$this->manager()->authenticateGateway($gatewayId, $plainToken);
} catch (Exception) {
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
}
$deadline = microtime(true) + max(0, $waitSeconds);
do {
$this->failTimedOutOperations($gatewayId);
$operation = $this->claimPendingOperation($gatewayId, $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId));
if ($operation !== null) {
return $operation;
}
if (microtime(true) >= $deadline) {
break;
}
usleep(self::POLL_INTERVAL_MICROSECONDS);
} while (true);
return null;
}
/**
* @throws Exception
*/
public function appendAgentOperationEvent(int $gatewayId, int $operationId, string $plainToken, array $payload): array
{
try {
$this->manager()->authenticateGateway($gatewayId, $plainToken);
} catch (Exception) {
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
}
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
return $this->serializeOperation($operation, true);
}
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
if (!in_array($level, [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR], true)) {
$level = self::LEVEL_INFO;
}
$message = trim((string)($payload['message'] ?? 'Operation event received'));
if ($message === '') {
$message = 'Operation event received';
}
$code = isset($payload['code']) ? trim((string)$payload['code']) : null;
$context = isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : [];
$this->appendEventRecord($gatewayId, $operationId, $level, $code, $message, $context);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['last_event_at'] = $this->now();
$summary['last_event_message'] = $message;
if (isset($context['progress'])) {
$summary['progress'] = max(0, min(100, (int)$context['progress']));
}
if (isset($context['label']) && trim((string)$context['label']) !== '') {
$summary['label'] = trim((string)$context['label']);
}
$operation->summary_json->set($summary);
$this->refreshOperationLease($operation);
return $this->serializeOperation($operation, true);
}
/**
* @throws Exception
*/
public function completeAgentOperation(int $gatewayId, int $operationId, string $plainToken, array $payload): array
{
try {
$this->manager()->authenticateGateway($gatewayId, $plainToken);
} catch (Exception) {
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
}
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
return $this->serializeOperation($operation, true);
}
$ok = (bool)($payload['ok'] ?? false);
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
$errorCode = trim((string)($payload['error_code'] ?? ''));
if (!$ok && $errorCode === '') {
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
}
if (!$ok && $errorMessage === '') {
$errorMessage = 'Gateway operation failed';
}
$operation->status->set($ok ? self::STATUS_COMPLETED : self::STATUS_FAILED);
$operation->result_json->set($result);
$operation->error_code->set($ok ? null : $errorCode);
$operation->error_message->set($ok ? null : $errorMessage);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = $ok ? 'Completed' : 'Failed';
$summary['progress'] = 100;
$summary['retryable'] = !$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION;
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
$operationId,
$ok ? self::LEVEL_INFO : self::LEVEL_ERROR,
$ok ? 'OPERATION_COMPLETED' : $errorCode,
$ok ? 'Operation completed successfully' : $errorMessage,
$result
);
$this->applyCompletionSideEffects($gatewayId, $operation, $ok, $result, $errorCode, $errorMessage);
return $this->serializeOperation($operation, true);
}
private static function normalizeOperationType(string $type): string
{
$normalized = strtoupper(trim($type));
if (in_array($normalized, [self::TYPE_DISCOVERY, self::TYPE_UPDATE, self::TYPE_UNINSTALL], true)) {
return $normalized;
}
throw new edge_gateway_operation_exception(
'Unsupported gateway operation type',
self::ERROR_VALIDATION,
422
);
}
/**
* @throws Exception
*/
private function validateOperationRequest(edge_gateways_o $gateway, string $type, array $request): array
{
if ($type === self::TYPE_DISCOVERY) {
return $request;
}
if ($type === self::TYPE_UPDATE) {
$targetVersion = trim((string)($request['target_version'] ?? ''));
if ($targetVersion === '') {
throw new edge_gateway_operation_exception(
'Update operations require target_version',
self::ERROR_VALIDATION,
422
);
}
$releaseChannel = trim((string)($request['release_channel'] ?? $gateway->release_channel->value() ?? edge_gateway_manager::DEFAULT_RELEASE_CHANNEL));
if ($releaseChannel === '') {
$releaseChannel = edge_gateway_manager::DEFAULT_RELEASE_CHANNEL;
}
return array_merge(
$this->manager()->buildUpdateOperationRequest($targetVersion, $releaseChannel),
$request,
[
'target_version' => $targetVersion,
'release_channel' => $releaseChannel,
]
);
}
if ($type === self::TYPE_UNINSTALL) {
return array_merge([
'service_name' => edge_gateway_manager::DEFAULT_AGENT_SERVICE_NAME,
'install_dir' => '/opt/truckwash-edge-agent',
], $request);
}
return $request;
}
/**
* @throws Exception
*/
private function claimPendingOperation(int $gatewayId, string $agentInstanceId): ?array
{
$pdo = db::getPDO();
$pdo->beginTransaction();
try {
$statement = $pdo->prepare(
"SELECT id
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND status = 'PENDING'
ORDER BY id ASC
LIMIT 1
FOR UPDATE"
);
$statement->execute([':gateway_id' => $gatewayId]);
$row = $statement->fetch();
if (!is_array($row) || empty($row['id'])) {
$pdo->commit();
return null;
}
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$operation->status->set(self::STATUS_IN_PROGRESS);
$operation->started_at->set($this->now());
$operation->agent_instance_id->set($agentInstanceId);
$operation->last_progress_at->set($this->now());
$operation->lease_expires_at->set($this->leaseExpiry());
$operation->attempt_count->set(((int)($operation->attempt_count->value() ?? 0)) + 1);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = 'Gateway is processing the operation';
$summary['progress'] = max(5, (int)($summary['progress'] ?? 0));
$summary['claimed_by'] = $agentInstanceId;
$operation->summary_json->set($summary);
$pdo->commit();
$this->appendEventRecord(
$gatewayId,
(int)$operation->id,
self::LEVEL_INFO,
'OPERATION_STARTED',
'Gateway started processing the operation',
[
'type' => (string)$operation->type->value(),
'agent_instance_id' => $agentInstanceId,
'attempt_count' => (int)($operation->attempt_count->value() ?? 1),
]
);
return $this->serializeOperation($operation, true);
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $throwable;
}
}
/**
* @throws Exception
*/
private function failTimedOutOperations(int $gatewayId): void
{
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'status' => self::STATUS_IN_PROGRESS,
'deleted_at' => null,
], ['id']);
$now = time();
foreach ($rows as $row) {
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value());
$leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value());
$timedOut = $startedAt !== false
&& $startedAt !== null
&& ($now - $startedAt) >= self::OPERATION_TIMEOUT_SECONDS;
$leaseExpired = $leaseExpiresAt !== false
&& $leaseExpiresAt !== null
&& $leaseExpiresAt <= $now;
if (!$timedOut && !$leaseExpired) {
continue;
}
$errorMessage = $leaseExpired
? 'Gateway stopped reporting operation progress before the lease expired'
: 'Gateway operation timed out';
$summaryLabel = $leaseExpired ? 'Lease expired' : 'Timed out';
$operation->status->set(self::STATUS_FAILED);
$operation->completed_at->set($this->now());
$operation->error_code->set(self::ERROR_OPERATION_TIMEOUT);
$operation->error_message->set($errorMessage);
$operation->lease_expires_at->set(null);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = $summaryLabel;
$summary['retryable'] = true;
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
(int)$operation->id,
self::LEVEL_ERROR,
self::ERROR_OPERATION_TIMEOUT,
$errorMessage,
[
'agent_instance_id' => $operation->agent_instance_id->value(),
'last_progress_at' => $operation->last_progress_at->value(),
]
);
}
}
/**
* @throws Exception
*/
private function applyCompletionSideEffects(
int $gatewayId,
edge_gateway_operations_o $operation,
bool $ok,
array $result,
string $errorCode,
string $errorMessage
): void {
$gateway = $this->requireGateway($gatewayId);
$metadata = (array)($gateway->metadata_json->value() ?? []);
$type = (string)$operation->type->value();
$now = $this->now();
$metadata['last_operation'] = [
'id' => (int)$operation->id,
'type' => $type,
'status' => $ok ? self::STATUS_COMPLETED : self::STATUS_FAILED,
'completed_at' => $now,
'error_code' => $ok ? null : $errorCode,
];
if ($type === self::TYPE_DISCOVERY) {
if ($ok) {
$inventory = isset($result['inventory']) && is_array($result['inventory']) ? (array)$result['inventory'] : [];
$this->manager()->syncGatewayInventory($gatewayId, $inventory);
$gateway->discovery_status->set('READY');
$metadata['last_discovery_completed_at'] = $now;
$metadata['last_discovery_error'] = null;
} else {
$gateway->discovery_status->set('FAILED');
$metadata['last_discovery_error'] = [
'code' => $errorCode,
'message' => $errorMessage,
'at' => $now,
];
}
} elseif ($type === self::TYPE_UPDATE) {
if ($ok) {
$installedVersion = trim((string)($result['installed_version'] ?? $result['target_version'] ?? $operation->request_json->value()['target_version'] ?? ''));
if ($installedVersion !== '') {
$gateway->installed_version->set($installedVersion);
$gateway->target_version->set($installedVersion);
}
$metadata['last_update_completed_at'] = $now;
$metadata['last_update_error'] = null;
} else {
$metadata['last_update_error'] = [
'code' => $errorCode,
'message' => $errorMessage,
'at' => $now,
];
}
} elseif ($type === self::TYPE_UNINSTALL) {
if ($ok) {
$gateway->status->set(edge_gateway_manager::STATUS_OFFLINE);
$metadata['uninstalled_at'] = $now;
$metadata['uninstall_error'] = null;
} else {
$metadata['uninstall_error'] = [
'code' => $errorCode,
'message' => $errorMessage,
'at' => $now,
];
}
}
$gateway->metadata_json->set($metadata);
$this->manager()->logGatewayAudit(
$gatewayId,
(int)$gateway->department_id->value(),
$ok ? 'GATEWAY_OPERATION_COMPLETED' : 'GATEWAY_OPERATION_FAILED',
null,
[
'operation_id' => (int)$operation->id,
'type' => $type,
'status' => $ok ? self::STATUS_COMPLETED : self::STATUS_FAILED,
'error_code' => $ok ? null : $errorCode,
]
);
}
/**
* @throws Exception
*/
private function serializeOperation(edge_gateway_operations_o $operation, bool $includeEvents = true): array
{
$operationArray = $operation->asArray();
if ($includeEvents) {
$operationArray['events'] = $this->listOperationEvents(
(int)$operation->gateway_id->value(),
(int)$operation->id,
20
);
}
return $operationArray;
}
/**
* @throws Exception
*/
private function requireGateway(int $gatewayId): edge_gateways_o
{
$gateway = (new edge_gateways_o())->select($gatewayId);
if (!$gateway->exists() || $gateway->deleted_at->value() !== null) {
throw new Exception('Edge gateway not found');
}
return $gateway;
}
/**
* @throws Exception
*/
private function requireOperation(int $gatewayId, int $operationId): edge_gateway_operations_o
{
$operation = (new edge_gateway_operations_o())->select($operationId);
if (!$operation->exists() || $operation->deleted_at->value() !== null) {
throw new Exception('Edge gateway operation not found');
}
if ((int)$operation->gateway_id->value() !== $gatewayId) {
throw new Exception('Edge gateway operation does not belong to this gateway');
}
return $operation;
}
private function appendEventRecord(
int $gatewayId,
int $operationId,
string $level,
?string $code,
string $message,
array $context
): array {
$eventId = (new edge_gateway_operation_events_o())->add_object([
'operation_id' => $operationId,
'gateway_id' => $gatewayId,
'level' => $level,
'code' => $code,
'message' => $message,
'context_json' => $context,
]);
return (new edge_gateway_operation_events_o())->select($eventId)->asArray();
}
private function refreshOperationLease(edge_gateway_operations_o $operation): void
{
$operation->last_progress_at->set($this->now());
$operation->lease_expires_at->set($this->leaseExpiry());
}
/**
* @throws Exception
*/
private function classifyCompletionError(int $gatewayId, string $errorMessage): string
{
$gateway = $this->requireGateway($gatewayId);
$gatewayStatus = edge_gateway_manager::resolveGatewayStatus(
(string)$gateway->status->value(),
$gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
);
if ($gatewayStatus === edge_gateway_manager::STATUS_OFFLINE) {
return self::ERROR_OFFLINE;
}
if ($gatewayStatus === edge_gateway_manager::STATUS_DEGRADED) {
return self::ERROR_STALE_HEARTBEAT;
}
$normalizedMessage = strtolower(trim($errorMessage));
if (str_contains($normalizedMessage, 'version')) {
return self::ERROR_UNSUPPORTED_VERSION;
}
if (str_contains($normalizedMessage, 'validation')) {
return self::ERROR_VALIDATION;
}
return self::ERROR_VALIDATION;
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
private function now(): string
{
return date('Y-m-d H:i:s');
}
private function leaseExpiry(): string
{
return date('Y-m-d H:i:s', time() + self::OPERATION_LEASE_SECONDS);
}
private function normalizeAgentInstanceId(?string $agentInstanceId, int $gatewayId): string
{
$candidate = trim((string)$agentInstanceId);
if ($candidate === '') {
return 'gateway-' . $gatewayId;
}
return substr($candidate, 0, 128);
}
}
@@ -0,0 +1,92 @@
<?php
namespace classes;
use Exception;
class edge_gateway_registry_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
public function createInstallToken(int $departmentId, ?string $label, ?int $createdBy = null): array
{
return $this->manager()->createInstallToken($departmentId, $label, $createdBy);
}
/**
* @throws Exception
*/
public function verifyInstallToken(string $plainToken): array
{
return $this->manager()->verifyInstallToken($plainToken);
}
/**
* @throws Exception
*/
public function claimGateway(
string $token,
string $hostname,
?string $installedVersion = null,
array $metadata = []
): array {
$payload = $this->manager()->claimGateway($token, $hostname, $installedVersion, $metadata);
unset($payload['broker_url']);
return $payload;
}
/**
* @throws Exception
*/
public function recordHeartbeat(int $gatewayId, string $plainToken, array $payload): array
{
return $this->manager()->recordHeartbeat($gatewayId, $plainToken, $payload);
}
/**
* @throws Exception
*/
public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array
{
return $this->manager()->updateGatewayMetadata($gatewayId, $payload, $userId);
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function setRelayBindings(int $gatewayId, array $bindings, ?int $userId = null): array
{
return $this->manager()->setRelayBindings($gatewayId, $bindings, $userId);
}
/**
* @throws Exception
*/
public function deleteGateway(int $gatewayId, ?int $userId = null): array
{
return $this->manager()->deleteGateway($gatewayId, $userId);
}
/**
* @throws Exception
*/
public function setDepartmentTransportMode(int $departmentId, string $transportMode, ?int $userId = null): array
{
return $this->manager()->setDepartmentTransportMode($departmentId, $transportMode, $userId);
}
public function getDepartmentTransportMode(int $departmentId): string
{
return $this->manager()->getDepartmentTransportMode($departmentId);
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
@@ -89,6 +89,7 @@ class edge_gateway_schema_bootstrap
device_id VARCHAR(255) NOT NULL,
local_ip VARCHAR(64) NULL,
channel INT NOT NULL DEFAULT 0,
fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL',
binding_source VARCHAR(64) NOT NULL DEFAULT 'MANUAL',
approved_by INT NULL,
approved_at DATETIME NULL,
@@ -123,81 +124,47 @@ class edge_gateway_schema_bootstrap
INDEX idx_edge_gateway_command_type (command_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs (
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
command_job_id INT NULL,
target_version VARCHAR(64) NOT NULL,
release_channel VARCHAR(32) NOT NULL DEFAULT 'stable',
type VARCHAR(32) NOT NULL,
operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
request_json JSON NULL,
summary_json JSON NULL,
result_json JSON NULL,
error_code VARCHAR(128) NULL,
error_message TEXT NULL,
correlation_id VARCHAR(128) NOT NULL,
agent_instance_id VARCHAR(128) NULL,
lease_expires_at DATETIME NULL,
last_progress_at DATETIME NULL,
attempt_count INT NOT NULL DEFAULT 0,
requested_by INT NULL,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
delivery_json JSON NULL,
result_json JSON 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,
INDEX idx_edge_gateway_update_gateway (gateway_id),
INDEX idx_edge_gateway_update_command (command_job_id),
INDEX idx_edge_gateway_update_status (status)
UNIQUE KEY uniq_edge_gateway_operation_correlation (correlation_id),
INDEX idx_edge_gateway_operations_gateway (gateway_id),
INDEX idx_edge_gateway_operations_status (status),
INDEX idx_edge_gateway_operations_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions (
"CREATE TABLE IF NOT EXISTS edge_gateway_operation_events (
id INT AUTO_INCREMENT PRIMARY KEY,
operation_id INT NOT NULL,
gateway_id INT NOT NULL,
reason TEXT NOT NULL,
approval_status VARCHAR(32) NOT NULL DEFAULT 'APPROVED',
session_token_hash VARCHAR(255) NOT NULL,
requested_by INT NULL,
approved_by INT NULL,
approved_at DATETIME NULL,
expires_at DATETIME NOT NULL,
opened_at DATETIME NULL,
closed_at DATETIME NULL,
transcript_text LONGTEXT NULL,
metadata_json JSON NULL,
level VARCHAR(16) NOT NULL DEFAULT 'INFO',
code VARCHAR(128) NULL,
message TEXT NOT NULL,
context_json JSON 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,
INDEX idx_edge_gateway_shell_gateway (gateway_id),
INDEX idx_edge_gateway_shell_expires (expires_at),
INDEX idx_edge_gateway_shell_status (approval_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_action_jobs (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
session_id INT NOT NULL,
action_type VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
payload_json JSON NULL,
delivery_json JSON NULL,
requested_by INT NULL,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
error_message TEXT 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,
INDEX idx_edge_gateway_shell_action_gateway (gateway_id),
INDEX idx_edge_gateway_shell_action_session (session_id),
INDEX idx_edge_gateway_shell_action_status (status),
INDEX idx_edge_gateway_shell_action_type (action_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_events (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
session_id INT NOT NULL,
event_type VARCHAR(32) NOT NULL,
payload_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_edge_gateway_shell_event_gateway (gateway_id),
INDEX idx_edge_gateway_shell_event_session (session_id),
INDEX idx_edge_gateway_shell_event_type (event_type)
INDEX idx_edge_gateway_operation_events_operation (operation_id),
INDEX idx_edge_gateway_operation_events_gateway (gateway_id),
INDEX idx_edge_gateway_operation_events_level (level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs (
@@ -220,38 +187,58 @@ class edge_gateway_schema_bootstrap
$db->query($sql);
}
if (!self::tableHasColumn('edge_gateway_update_jobs', 'command_job_id')) {
$db->query(
"ALTER TABLE edge_gateway_update_jobs
ADD COLUMN command_job_id INT NULL AFTER gateway_id,
ADD INDEX idx_edge_gateway_update_command (command_job_id)"
);
}
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
if (!self::tableHasColumn('edge_gateway_command_jobs', 'delivery_json')) {
$db->query(
"ALTER TABLE edge_gateway_command_jobs
ADD COLUMN delivery_json JSON NULL AFTER response_json"
);
}
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
if (!self::tableHasColumn('edge_gateway_shell_action_jobs', 'delivery_json')) {
$db->query(
"ALTER TABLE edge_gateway_shell_action_jobs
ADD COLUMN delivery_json JSON NULL AFTER payload_json"
);
}
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
self::ensureColumn('edge_gateway_operations', 'operation_type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type");
self::ensureColumn('edge_gateway_operations', 'summary_json', 'JSON NULL AFTER request_json');
self::ensureColumn('edge_gateway_operations', 'result_json', 'JSON NULL AFTER summary_json');
self::ensureColumn('edge_gateway_operations', 'error_code', 'VARCHAR(128) NULL AFTER result_json');
self::ensureColumn('edge_gateway_operations', 'error_message', 'TEXT NULL AFTER error_code');
self::ensureColumn('edge_gateway_operations', 'correlation_id', 'VARCHAR(128) NULL AFTER error_message');
self::ensureColumn('edge_gateway_operations', 'agent_instance_id', 'VARCHAR(128) NULL AFTER correlation_id');
self::ensureColumn('edge_gateway_operations', 'lease_expires_at', 'DATETIME NULL AFTER agent_instance_id');
self::ensureColumn('edge_gateway_operations', 'last_progress_at', 'DATETIME NULL AFTER lease_expires_at');
self::ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at');
self::ensureColumn('edge_gateway_operations', 'requested_by', 'INT NULL AFTER attempt_count');
self::ensureColumn('edge_gateway_operations', 'requested_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER requested_by');
self::ensureColumn('edge_gateway_operations', 'started_at', 'DATETIME NULL AFTER requested_at');
self::ensureColumn('edge_gateway_operations', 'completed_at', 'DATETIME NULL AFTER started_at');
self::ensureColumn('edge_gateway_operations', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
self::ensureColumn('edge_gateway_operations', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at');
if (!self::tableHasColumn('edge_gateway_update_jobs', 'delivery_json')) {
$db->query(
"ALTER TABLE edge_gateway_update_jobs
ADD COLUMN delivery_json JSON NULL AFTER completed_at"
);
}
self::ensureColumn('edge_gateway_operation_events', 'code', 'VARCHAR(128) NULL AFTER level');
self::ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message');
self::ensureColumn('edge_gateway_audit_logs', 'actor_type', "VARCHAR(32) NOT NULL DEFAULT 'USER' AFTER actor_user_id");
self::ensureColumn('edge_gateway_audit_logs', 'severity', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER actor_type");
self::ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity');
self::syncOperationTypeColumns();
self::$initialized = true;
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
if (self::tableHasColumn($table, $column)) {
return;
}
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
throw new \RuntimeException('Invalid schema bootstrap identifier');
}
$db->query(
"ALTER TABLE `$table`
ADD COLUMN `$column` $definition"
);
}
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
@@ -275,4 +262,30 @@ class edge_gateway_schema_bootstrap
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
private static function syncOperationTypeColumns(): void
{
global $db;
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
return;
}
$db->query(
"UPDATE edge_gateway_operations
SET type = operation_type
WHERE operation_type IS NOT NULL
AND operation_type <> ''
AND (type IS NULL OR type = '' OR type <> operation_type)"
);
$db->query(
"UPDATE edge_gateway_operations
SET operation_type = type
WHERE type IS NOT NULL
AND type <> ''
AND (operation_type IS NULL OR operation_type = '')"
);
}
}
@@ -0,0 +1,45 @@
<?php
namespace classes;
use Exception;
class edge_gateway_view_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listGateways(?int $departmentId = null, bool $includeDetail = true): array
{
return $this->manager()->listGateways($departmentId, $includeDetail);
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,mixed>
* @throws Exception
*/
public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
{
return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways);
}
/**
* @throws Exception
*/
public function getGateway(int $gatewayId): array
{
return $this->manager()->getGateway($gatewayId);
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
+24 -1
View File
@@ -39,6 +39,8 @@ use Psr\Http\Client\ClientExceptionInterface;
#[AllowDynamicProperties] class email implements email_i
{
public static array $fake_deliveries = [];
/**
* Configuration for the email service
* @var email_c
@@ -125,6 +127,17 @@ use Psr\Http\Client\ClientExceptionInterface;
*/
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
{
if (self::isFakeDeliveryEnabled()) {
self::$fake_deliveries[] = [
'to' => $to,
'recipient_name' => $recipient_name,
'subject' => $subject,
'message' => $message,
'html' => $html,
];
return;
}
// Check if the email is blacklisted
$blacklisted_emails = [
'invoice.dk@freja.com', // TODO: Make this dynamic.
@@ -209,6 +222,16 @@ use Psr\Http\Client\ClientExceptionInterface;
$this->sendEmailMailerSend($to, $recipient_name, 'Betalingslink for bestilling #' . $order_id, '', $html);
}
public static function resetFakeDeliveries(): void
{
self::$fake_deliveries = [];
}
private static function isFakeDeliveryEnabled(): bool
{
return getenv('EMAIL_FAKE_MODE') === '1';
}
/**
* Send a booking confirmation email
* @throws MailerSendException
@@ -488,4 +511,4 @@ use Psr\Http\Client\ClientExceptionInterface;
$this->attachments
);
}
}
}
@@ -0,0 +1,201 @@
<?php
namespace classes;
use Throwable;
class order_bookings_counts_cache
{
public const PREFIX = 'order_bookings:counts:v1:';
/**
* Optional runtime adapter for tests.
*/
private static ?object $adapter = null;
public static function setAdapterForTests(?object $adapter): void
{
self::$adapter = $adapter;
}
public static function getTtl(): int
{
$raw = getenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 30;
}
return max(0, (int)$raw);
}
public static function buildKey(array $context): string
{
$normalized = self::normalizeValue($context);
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
$encoded = serialize($normalized);
}
return self::PREFIX . md5($encoded);
}
public static function getCounts(string $key): ?array
{
$raw = self::redisGet($key);
if ($raw === null || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return self::normalizeCounts($decoded);
}
public static function storeCounts(string $key, array $counts, ?int $ttl = null): void
{
$cacheTtl = $ttl ?? self::getTtl();
if ($cacheTtl <= 0) {
return;
}
$normalized = self::normalizeCounts($counts);
if ($normalized === null) {
return;
}
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return;
}
self::redisSetEx($key, $encoded, $cacheTtl);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
}
private static function normalizeCounts(array $counts): ?array
{
if (!array_key_exists('past', $counts) || !array_key_exists('current', $counts) || !array_key_exists('future', $counts)) {
return null;
}
return [
'past' => max(0, (int)$counts['past']),
'current' => max(0, (int)$counts['current']),
'future' => max(0, (int)$counts['future']),
];
}
private static function normalizeValue(mixed $value): mixed
{
if (!is_array($value)) {
return self::normalizeScalar($value);
}
$normalized = array_map([self::class, 'normalizeValue'], $value);
if (array_is_list($normalized)) {
if (self::isScalarList($normalized)) {
sort($normalized);
}
return $normalized;
}
ksort($normalized);
return $normalized;
}
private static function normalizeScalar(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
if (preg_match('/^-?\d+$/', $value) === 1) {
return (int)$value;
}
if (is_numeric($value)) {
return (float)$value;
}
return $value;
}
private static function isScalarList(array $value): bool
{
foreach ($value as $item) {
if (is_array($item) || is_object($item)) {
return false;
}
}
return true;
}
private static function clearPattern(string $pattern): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->clear_keys($pattern);
} catch (Throwable) {
// Cache invalidation must never break request flow.
}
}
private static function redisSetEx(string $key, string $value, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->setEx($key, $value, $ttl);
} catch (Throwable) {
// Best-effort cache write.
}
}
private static function redisGet(string $key): ?string
{
try {
$client = self::redisClient();
if ($client === null) {
return null;
}
$value = $client->get($key);
return is_string($value) ? $value : null;
} catch (Throwable) {
return null;
}
}
private static function redisClient(): ?object
{
if (self::$adapter !== null) {
return self::$adapter;
}
try {
if (defined('redis')) {
$instance = constant('redis');
if (is_object($instance)) {
return $instance;
}
}
return (new redis())->connect();
} catch (Throwable) {
return null;
}
}
}
@@ -0,0 +1,190 @@
<?php
namespace classes;
use Throwable;
class order_bookings_list_cache
{
public const PREFIX = 'order_bookings:list:v1:';
/**
* Optional runtime adapter for tests.
*/
private static ?object $adapter = null;
public static function setAdapterForTests(?object $adapter): void
{
self::$adapter = $adapter;
}
public static function getTtl(): int
{
$raw = getenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 30;
}
return max(0, (int)$raw);
}
public static function buildKey(array $context): string
{
$normalized = self::normalizeValue($context);
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
$encoded = serialize($normalized);
}
return self::PREFIX . md5($encoded);
}
public static function getPayload(string $key): ?array
{
$raw = self::redisGet($key);
if ($raw === null || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
if (!array_key_exists('success', $decoded) || !array_key_exists('data', $decoded)) {
return null;
}
$decoded['meta'] = isset($decoded['meta']) && is_array($decoded['meta']) ? $decoded['meta'] : [];
$decoded['includes'] = isset($decoded['includes']) && is_array($decoded['includes']) ? $decoded['includes'] : [];
return $decoded;
}
public static function storePayload(string $key, array $payload, ?int $ttl = null): void
{
$cacheTtl = $ttl ?? self::getTtl();
if ($cacheTtl <= 0) {
return;
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return;
}
self::redisSetEx($key, $encoded, $cacheTtl);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
}
private static function normalizeValue(mixed $value): mixed
{
if (!is_array($value)) {
return self::normalizeScalar($value);
}
$normalized = array_map([self::class, 'normalizeValue'], $value);
if (array_is_list($normalized)) {
if (self::isScalarList($normalized)) {
sort($normalized);
}
return $normalized;
}
ksort($normalized);
return $normalized;
}
private static function normalizeScalar(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
if (preg_match('/^-?\d+$/', $value) === 1) {
return (int)$value;
}
if (is_numeric($value)) {
return (float)$value;
}
return $value;
}
private static function isScalarList(array $value): bool
{
foreach ($value as $item) {
if (is_array($item) || is_object($item)) {
return false;
}
}
return true;
}
private static function clearPattern(string $pattern): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->clear_keys($pattern);
} catch (Throwable) {
// Cache invalidation must never break request flow.
}
}
private static function redisSetEx(string $key, string $value, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->setEx($key, $value, $ttl);
} catch (Throwable) {
// Best-effort cache write.
}
}
private static function redisGet(string $key): ?string
{
try {
$client = self::redisClient();
if ($client === null) {
return null;
}
$value = $client->get($key);
return is_string($value) ? $value : null;
} catch (Throwable) {
return null;
}
}
private static function redisClient(): ?object
{
if (self::$adapter !== null) {
return self::$adapter;
}
try {
if (defined('redis')) {
$instance = constant('redis');
if (is_object($instance)) {
return $instance;
}
}
return (new redis())->connect();
} catch (Throwable) {
return null;
}
}
}
+10
View File
@@ -89,6 +89,16 @@ class response implements response_i
return $this->data;
}
public function get_meta(): array
{
return $this->meta;
}
public function get_includes(): array
{
return $this->includes;
}
#[NoReturn] public function not_found(): void
{
$this->error('Not found', 404);
+15 -1
View File
@@ -10,6 +10,7 @@ require_once WD . '/modules/stripe/endpoints/stripe_endpoint_prices.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_invoice.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_readers.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_payment_intents.php';
require_once WD . '/classes/stripe_fake_http_client.php';
// Require all helper classes
@@ -29,6 +30,7 @@ use stripe\endpoints\stripe_endpoint_prices;
use stripe\endpoints\stripe_endpoint_product;
use stripe\endpoints\stripe_endpoint_readers;
use stripe\stripe_c;
use Stripe\ApiRequestor;
use Stripe\StripeClient;
/**
@@ -104,6 +106,13 @@ class stripe implements stripe_i
{
// Get the stripe client
if (!isset($this->client)) {
if (self::isFakeModeEnabled()) {
ApiRequestor::setHttpClient(new stripe_fake_http_client());
$this->client = new StripeClient([
'api_key' => 'sk_test_fake'
]);
return $this->client;
}
// Require the module to be enabled
self::requireModuleEnabled();
// Require the secret key to be set
@@ -118,6 +127,11 @@ class stripe implements stripe_i
return $this->client;
}
private static function isFakeModeEnabled(): bool
{
return getenv('STRIPE_FAKE_MODE') === '1';
}
/**
* @inheritDoc
* @throws Exception
@@ -153,4 +167,4 @@ class stripe implements stripe_i
throw new Exception('Invalid publishable key');
}
}
}
}
@@ -0,0 +1,268 @@
<?php
namespace classes;
use Stripe\HttpClient\ClientInterface;
class stripe_fake_http_client implements ClientInterface
{
private const DEFAULT_STATE = [
'next_customer' => 1,
'next_price' => 1,
'next_invoice' => 1,
'next_invoice_item' => 1,
'customers' => [],
'prices' => [],
'products' => [],
'invoice_items' => [],
'invoices' => [],
];
public static function resetStore(): void
{
self::writeStore(self::DEFAULT_STATE);
}
public static function setInvoiceState(string $invoiceId, array $attributes): void
{
$store = self::readStore();
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return;
}
$store['invoices'][$invoiceId] = [
...$invoice,
...$attributes,
];
self::writeStore($store);
}
public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1')
{
$path = (string)parse_url((string)$absUrl, PHP_URL_PATH);
$store = self::readStore();
if ($method === 'post' && $path === '/v1/customers') {
$customerId = sprintf('cus_fake_%06d', (int)$store['next_customer']);
$store['next_customer']++;
$customer = [
'id' => $customerId,
'object' => 'customer',
'email' => (string)($params['email'] ?? ''),
];
$store['customers'][$customerId] = $customer;
self::writeStore($store);
return $this->jsonResponse($customer);
}
if ($method === 'get' && preg_match('#^/v1/customers/(?P<id>[^/]+)$#', $path, $matches)) {
$customer = $store['customers'][$matches['id']] ?? null;
if (!$customer) {
return $this->errorResponse(404, 'resource_missing', 'No such customer.');
}
return $this->jsonResponse($customer);
}
if ($method === 'get' && preg_match('#^/v1/products/(?P<id>[^/]+)$#', $path, $matches)) {
$product = $store['products'][$matches['id']] ?? null;
if (!$product) {
return $this->errorResponse(404, 'resource_missing', 'No such product.');
}
return $this->jsonResponse($product);
}
if ($method === 'post' && $path === '/v1/products') {
$productId = (string)($params['id'] ?? sprintf('prod_fake_%06d', count($store['products']) + 1));
$product = [
'id' => $productId,
'object' => 'product',
'name' => (string)($params['name'] ?? 'Fake Product'),
];
$store['products'][$productId] = $product;
self::writeStore($store);
return $this->jsonResponse($product);
}
if ($method === 'post' && $path === '/v1/prices') {
$priceId = sprintf('price_fake_%06d', (int)$store['next_price']);
$store['next_price']++;
$price = [
'id' => $priceId,
'object' => 'price',
'product' => (string)($params['product'] ?? ''),
'unit_amount' => (int)($params['unit_amount'] ?? 0),
'currency' => strtolower((string)($params['currency'] ?? 'dkk')),
];
$store['prices'][$priceId] = $price;
self::writeStore($store);
return $this->jsonResponse($price);
}
if ($method === 'post' && $path === '/v1/invoices') {
$invoiceId = sprintf('in_fake_%06d', (int)$store['next_invoice']);
$store['next_invoice']++;
$invoice = [
'id' => $invoiceId,
'object' => 'invoice',
'customer' => (string)($params['customer'] ?? ''),
'status' => 'draft',
'paid' => false,
'amount_due' => 0,
'amount_paid' => 0,
'collection_method' => (string)($params['collection_method'] ?? 'send_invoice'),
'hosted_invoice_url' => sprintf('https://stripe.test/invoices/%s', $invoiceId),
'metadata' => is_array($params['metadata'] ?? null) ? $params['metadata'] : [],
'lines' => [],
];
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'post' && $path === '/v1/invoiceitems') {
$invoiceId = (string)($params['invoice'] ?? '');
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoiceItemId = sprintf('ii_fake_%06d', (int)$store['next_invoice_item']);
$store['next_invoice_item']++;
$priceId = (string)($params['price'] ?? '');
$price = $store['prices'][$priceId] ?? ['unit_amount' => 0];
$invoiceItem = [
'id' => $invoiceItemId,
'object' => 'invoiceitem',
'invoice' => $invoiceId,
'customer' => (string)($params['customer'] ?? ''),
'price' => $priceId,
'amount' => (int)($price['unit_amount'] ?? 0),
];
$store['invoice_items'][$invoiceItemId] = $invoiceItem;
$store['invoices'][$invoiceId]['lines'][] = $invoiceItemId;
$store['invoices'][$invoiceId]['amount_due'] += (int)($price['unit_amount'] ?? 0);
self::writeStore($store);
return $this->jsonResponse($invoiceItem);
}
if ($method === 'post' && preg_match('#^/v1/invoices/(?P<id>[^/]+)/finalize$#', $path, $matches)) {
$invoiceId = $matches['id'];
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoice['status'] = 'open';
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'post' && preg_match('#^/v1/invoices/(?P<id>[^/]+)/void$#', $path, $matches)) {
$invoiceId = $matches['id'];
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoice['status'] = 'void';
$invoice['paid'] = false;
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'get' && preg_match('#^/v1/invoices/(?P<id>[^/]+)$#', $path, $matches)) {
$invoice = $store['invoices'][$matches['id']] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
return $this->jsonResponse($invoice);
}
return $this->errorResponse(404, 'resource_missing', 'Unsupported fake Stripe request: ' . $method . ' ' . $path);
}
private function jsonResponse(array $payload, int $status = 200): array
{
return [
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$status,
[
'Content-Type' => 'application/json',
'Request-Id' => 'req_fake_stripe',
],
];
}
private function errorResponse(int $status, string $code, string $message): array
{
return [
json_encode([
'error' => [
'type' => 'invalid_request_error',
'code' => $code,
'message' => $message,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$status,
[
'Content-Type' => 'application/json',
'Request-Id' => 'req_fake_stripe_error',
],
];
}
private static function readStore(): array
{
$path = self::storePath();
if (!is_file($path)) {
return self::DEFAULT_STATE;
}
$json = file_get_contents($path);
if ($json === false || trim($json) === '') {
return self::DEFAULT_STATE;
}
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return self::DEFAULT_STATE;
}
return [
...self::DEFAULT_STATE,
...$decoded,
];
}
private static function writeStore(array $store): void
{
file_put_contents(
self::storePath(),
json_encode($store, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
);
}
private static function storePath(): string
{
$path = trim((string)getenv('STRIPE_FAKE_STORE_PATH'));
if ($path !== '') {
return $path;
}
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-stripe-fake-store.json';
}
}
@@ -0,0 +1,258 @@
<?php
namespace classes;
use DateTime;
use DateTimeZone;
use Exception;
final class workfeed_shift_time_resolver
{
private const MAX_UNAPPROVED_EXTENSION_SECONDS = 6 * 3600;
private const MAX_UNAPPROVED_SHIFT_SPAN_SECONDS = 24 * 3600;
/**
* @return array{actualStart:DateTime,scheduledEnd:DateTime,actualEnd:DateTime,hasApproval:bool}|null
*/
public static function resolveShiftTiming(mixed $record_value): ?array
{
$record = self::normalizeRecord($record_value);
if ($record === []) {
return null;
}
$actual_start = self::firstDateTimeFromPaths($record, [
'actualStart',
'actualStartTime',
'clockIn',
'clockInTime',
'start',
'startTime',
'from',
'approval.originalStart',
]);
$scheduled_end = self::firstDateTimeFromPaths($record, [
'approval.originalEnd',
'end',
'endTime',
'to',
]);
$actual_only_end = self::firstDateTimeFromPaths($record, [
'actualEnd',
'actualEndTime',
'clockOut',
'clockOutTime',
]);
$current_end = self::firstDateTimeFromPaths($record, [
'end',
'endTime',
'to',
]);
$saved_actual_end = $actual_only_end ?? $current_end;
if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) {
return null;
}
$actual_end = self::resolveActualEnd($record, $actual_start, $scheduled_end, $saved_actual_end, $actual_only_end);
if ($actual_end->getTimestamp() <= $actual_start->getTimestamp()) {
return null;
}
return [
'actualStart' => $actual_start,
'scheduledEnd' => $scheduled_end,
'actualEnd' => $actual_end,
'hasApproval' => self::hasShiftApproval($record),
];
}
public static function calculateOvertimeHoursInRange(mixed $record_value, DateTime $range_start, DateTime $range_end_exclusive): float
{
$timing = self::resolveShiftTiming($record_value);
if ($timing === null) {
return 0.0;
}
$scheduled_end_ts = $timing['scheduledEnd']->getTimestamp();
$actual_end_ts = $timing['actualEnd']->getTimestamp();
if ($actual_end_ts <= $scheduled_end_ts) {
return 0.0;
}
$overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
$overtime_end_ts = min($actual_end_ts, $range_end_exclusive->getTimestamp());
if ($overtime_end_ts <= $overtime_start_ts) {
return 0.0;
}
return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
}
/**
* @return array<string,mixed>
*/
private static function normalizeRecord(mixed $record): array
{
if (is_array($record)) {
return $record;
}
if (is_object($record)) {
return get_object_vars($record);
}
return [];
}
private static function getNestedRecordValue(array $record, string $path): mixed
{
$segments = explode('.', $path);
$current = $record;
foreach ($segments as $segment) {
if (is_array($current)) {
if (!array_key_exists($segment, $current)) {
return null;
}
$current = $current[$segment];
continue;
}
if (is_object($current)) {
if (!property_exists($current, $segment)) {
return null;
}
$current = $current->$segment;
continue;
}
return null;
}
return $current;
}
/**
* @param array<int,string> $paths
*/
private static function firstDateTimeFromPaths(array $record, array $paths): ?DateTime
{
foreach ($paths as $path) {
$parsed = self::parseDateTimeValue(self::getNestedRecordValue($record, $path));
if ($parsed !== null) {
return $parsed;
}
}
return null;
}
private static function parseDateTimeValue(mixed $value): ?DateTime
{
if (is_string($value)) {
$normalized = trim($value);
if ($normalized === '') {
return null;
}
try {
return new DateTime($normalized);
} catch (Exception) {
if (!is_numeric($normalized)) {
return null;
}
$value = (float)$normalized;
}
}
if (is_int($value) || is_float($value)) {
if (!is_finite((float)$value)) {
return null;
}
$timestamp = (float)$value;
if ($timestamp > 9999999999) {
$timestamp /= 1000;
}
try {
$date = new DateTime('@' . (string)(int)round($timestamp));
$date->setTimezone(new DateTimeZone('UTC'));
return $date;
} catch (Exception) {
return null;
}
}
$record = self::normalizeRecord($value);
foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) {
if (!array_key_exists($key, $record)) {
continue;
}
$parsed = self::parseDateTimeValue($record[$key]);
if ($parsed !== null) {
return $parsed;
}
}
return null;
}
private static function hasShiftApproval(array $record): bool
{
if (!array_key_exists('approval', $record)) {
return false;
}
$approval = $record['approval'];
if ($approval === null) {
return false;
}
if (is_array($approval)) {
return $approval !== [];
}
if (is_object($approval)) {
return get_object_vars($approval) !== [];
}
return true;
}
private static function resolveActualEnd(
array $record,
DateTime $actual_start,
DateTime $scheduled_end,
DateTime $saved_actual_end,
?DateTime $actual_only_end
): DateTime {
if (self::hasShiftApproval($record) || $actual_only_end !== null) {
return $saved_actual_end;
}
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
if ($update_time === null) {
return $saved_actual_end;
}
$shift_start_ts = $actual_start->getTimestamp();
$scheduled_end_ts = $scheduled_end->getTimestamp();
$saved_actual_end_ts = $saved_actual_end->getTimestamp();
$update_ts = $update_time->getTimestamp();
$fallback_base_ts = max($saved_actual_end_ts, $scheduled_end_ts);
if ($update_ts <= $fallback_base_ts) {
return $saved_actual_end;
}
// Only use updateTime as an overtime hint when no explicit actual end was saved.
if (($update_ts - $scheduled_end_ts) > self::MAX_UNAPPROVED_EXTENSION_SECONDS) {
return $saved_actual_end;
}
if (($update_ts - $shift_start_ts) > self::MAX_UNAPPROVED_SHIFT_SPAN_SECONDS) {
return $saved_actual_end;
}
return $update_time;
}
}
@@ -0,0 +1,28 @@
<?php
namespace config;
use traits\module_config_variable;
class economic_transaction_draft_customer_number_c
{
use module_config_variable;
/**
* @throws \Exception
*/
public function __construct()
{
self::setupConfigVariable(
'economic',
'transactionDraftCustomerNumber',
'int',
false,
null,
'Customer number used for transaction drafts that must not export to e-conomic',
'99999999',
false,
null
);
}
}
@@ -4,12 +4,14 @@ require_once WD . '/modules/economic/config/economic_payment_terms_c.php';
require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
require_once WD . '/modules/economic/config/economic_fee_product_id_c.php';
require_once WD . '/modules/economic/config/economic_transaction_draft_customer_number_c.php';
use config\economic_invoice_layout_c;
use config\economic_payment_terms_c;
use config\economic_admin_fee_monthly_c;
use config\economic_admin_fee_order_c;
use config\economic_fee_product_id_c;
use config\economic_transaction_draft_customer_number_c;
use traits\module_config_t;
class economic_c
@@ -21,6 +23,7 @@ class economic_c
public economic_admin_fee_monthly_c $admin_fee_monthly;
public economic_admin_fee_order_c $admin_fee_order;
public economic_fee_product_id_c $fee_product_id;
public economic_transaction_draft_customer_number_c $transaction_draft_customer_number;
public function __construct()
{
@@ -30,12 +33,14 @@ class economic_c
economic_payment_terms_c::class,
economic_admin_fee_monthly_c::class,
economic_admin_fee_order_c::class,
economic_fee_product_id_c::class
economic_fee_product_id_c::class,
economic_transaction_draft_customer_number_c::class,
]);
$this->invoice_layout = new economic_invoice_layout_c();
$this->payment_terms = new economic_payment_terms_c();
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
$this->admin_fee_order = new economic_admin_fee_order_c();
$this->fee_product_id = new economic_fee_product_id_c();
$this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c();
}
}
}
@@ -26,6 +26,14 @@ class stripe_endpoint_invoice
return self::getClient()->invoices->retrieve($id);
}
/**
* @throws ApiErrorException
*/
public function void(string $id): Invoice
{
return self::getClient()->invoices->voidInvoice($id);
}
/**
* @throws ApiErrorException
* @throws Exception
@@ -53,13 +61,47 @@ class stripe_endpoint_invoice
return $this->create(
$line_items,
$stripe_customer_id,
[
'order_id' => $order_id,
'customer_id' => $stripe_customer_id
]
$this->buildOrderMetadata($order, $stripe_customer_id)
);
}
private function buildOrderMetadata(orders_o $order, string $stripe_customer_id): array
{
$metadata = [
'order_id' => (string)$order->id,
'customer_id' => (string)$order->customer_id->value(),
'stripe_customer_id' => $stripe_customer_id,
'department_id' => (string)$order->department_id->value(),
];
$reference = trim((string)$order->reference->value());
if ($reference !== '') {
$metadata['reference'] = $reference;
}
$po = trim((string)$order->po->value());
if ($po !== '') {
$metadata['po'] = $po;
}
$reg1 = trim((string)$order->reg_1->value());
if ($reg1 !== '') {
$metadata['reg_1'] = $reg1;
}
$reg2 = trim((string)$order->reg_2->value());
if ($reg2 !== '') {
$metadata['reg_2'] = $reg2;
}
$reg3 = trim((string)$order->reg_3->value());
if ($reg3 !== '') {
$metadata['reg_3'] = $reg3;
}
return $metadata;
}
/**
* Create a new payment link
* @param stripe_line_items $line_items
@@ -96,4 +138,4 @@ class stripe_endpoint_invoice
return $invoice->finalizeInvoice();
}
}
}
@@ -426,6 +426,7 @@ class collected_order_invoices_o extends db
if (empty($this->customer_number->value())) {
throw new Exception('Customer number is not set');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
if (!$ignore_closed) {
// Require the invoice collection to be open
self::requireOpen();
@@ -479,6 +480,7 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
// Check if the invoice draft already exists
if (self::isDraftExisting() || self::isBooked()) {
throw new Exception('Invoice draft already exists, or invoice collection is already booked');
@@ -1487,4 +1489,4 @@ class collected_order_invoices_o extends db
{
$this->objectChanged();
}
}
}
@@ -0,0 +1,58 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_operation_events_o extends db
{
use db_object_t;
public object_property $operation_id;
public object_property $gateway_id;
public object_property $level;
public object_property $code;
public object_property $message;
public object_property $context_json;
public object_property $created_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_operation_events');
}
public function getObjectProperties(): void
{
$this->operation_id = new object_property($this->table, $this->id, 'operation_id', 'int', false);
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
$this->code = new object_property($this->table, $this->id, 'code', 'string', false);
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'operation_id' => (int)$this->operation_id->value(),
'gateway_id' => (int)$this->gateway_id->value(),
'level' => (string)$this->level->value(),
'code' => $this->code->value() === null ? null : (string)$this->code->value(),
'message' => (string)$this->message->value(),
'context' => (array)($this->context_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
];
}
}
@@ -7,21 +7,28 @@ use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_update_jobs_o extends db
class edge_gateway_operations_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $command_job_id;
public object_property $target_version;
public object_property $release_channel;
public object_property $type;
public object_property $operation_type;
public object_property $status;
public object_property $request_json;
public object_property $summary_json;
public object_property $result_json;
public object_property $error_code;
public object_property $error_message;
public object_property $correlation_id;
public object_property $agent_instance_id;
public object_property $lease_expires_at;
public object_property $last_progress_at;
public object_property $attempt_count;
public object_property $requested_by;
public object_property $requested_at;
public object_property $started_at;
public object_property $completed_at;
public object_property $delivery_json;
public object_property $result_json;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
@@ -29,22 +36,29 @@ class edge_gateway_update_jobs_o extends db
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_update_jobs');
$this->setTable('edge_gateway_operations');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->command_job_id = new object_property($this->table, $this->id, 'command_job_id', 'int', false);
$this->target_version = new object_property($this->table, $this->id, 'target_version', 'string', false);
$this->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false);
$this->type = new object_property($this->table, $this->id, 'type', 'string', false);
$this->operation_type = new object_property($this->table, $this->id, 'operation_type', 'string', false);
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
$this->request_json = new object_property($this->table, $this->id, 'request_json', 'json', false);
$this->summary_json = new object_property($this->table, $this->id, 'summary_json', 'json', false);
$this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false);
$this->error_code = new object_property($this->table, $this->id, 'error_code', 'string', false);
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false);
$this->correlation_id = new object_property($this->table, $this->id, 'correlation_id', 'string', false);
$this->agent_instance_id = new object_property($this->table, $this->id, 'agent_instance_id', 'string', false);
$this->lease_expires_at = new object_property($this->table, $this->id, 'lease_expires_at', 'string', false);
$this->last_progress_at = new object_property($this->table, $this->id, 'last_progress_at', 'string', false);
$this->attempt_count = new object_property($this->table, $this->id, 'attempt_count', 'int', false);
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
$this->started_at = new object_property($this->table, $this->id, 'started_at', 'string', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
$this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', 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);
@@ -61,16 +75,22 @@ class edge_gateway_update_jobs_o extends db
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'command_job_id' => $this->command_job_id->value() === null ? null : (int)$this->command_job_id->value(),
'target_version' => (string)$this->target_version->value(),
'release_channel' => (string)$this->release_channel->value(),
'type' => (string)($this->type->value() ?? $this->operation_type->value() ?? ''),
'status' => (string)$this->status->value(),
'request' => (array)($this->request_json->value() ?? []),
'summary' => (array)($this->summary_json->value() ?? []),
'result' => (array)($this->result_json->value() ?? []),
'error_code' => $this->error_code->value() === null ? null : (string)$this->error_code->value(),
'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(),
'correlation_id' => (string)$this->correlation_id->value(),
'agent_instance_id' => $this->agent_instance_id->value() === null ? null : (string)$this->agent_instance_id->value(),
'lease_expires_at' => $this->lease_expires_at->value() === null ? null : (string)$this->lease_expires_at->value(),
'last_progress_at' => $this->last_progress_at->value() === null ? null : (string)$this->last_progress_at->value(),
'attempt_count' => (int)($this->attempt_count->value() ?? 0),
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
'requested_at' => (string)$this->requested_at->value(),
'started_at' => $this->started_at->value() === null ? null : (string)$this->started_at->value(),
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
'delivery' => (array)($this->delivery_json->value() ?? []),
'result' => (array)($this->result_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
];
@@ -1,75 +0,0 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_shell_action_jobs_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $session_id;
public object_property $action_type;
public object_property $status;
public object_property $payload_json;
public object_property $delivery_json;
public object_property $requested_by;
public object_property $requested_at;
public object_property $completed_at;
public object_property $error_message;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_shell_action_jobs');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
$this->action_type = new object_property($this->table, $this->id, 'action_type', 'string', false);
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
$this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false);
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', 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);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'session_id' => (int)$this->session_id->value(),
'action_type' => (string)$this->action_type->value(),
'status' => (string)$this->status->value(),
'payload' => (array)($this->payload_json->value() ?? []),
'delivery' => (array)($this->delivery_json->value() ?? []),
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
'requested_at' => (string)$this->requested_at->value(),
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(),
'created_at' => (string)$this->created_at->value(),
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
];
}
}
@@ -1,54 +0,0 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_shell_events_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $session_id;
public object_property $event_type;
public object_property $payload_json;
public object_property $created_at;
public object_property $deleted_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_shell_events');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
$this->event_type = new object_property($this->table, $this->id, 'event_type', 'string', false);
$this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'session_id' => (int)$this->session_id->value(),
'event_type' => (string)$this->event_type->value(),
'payload' => (array)($this->payload_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
];
}
}
@@ -1,80 +0,0 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_shell_sessions_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $reason;
public object_property $approval_status;
public object_property $session_token_hash;
public object_property $requested_by;
public object_property $approved_by;
public object_property $approved_at;
public object_property $expires_at;
public object_property $opened_at;
public object_property $closed_at;
public object_property $transcript_text;
public object_property $metadata_json;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_shell_sessions');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->reason = new object_property($this->table, $this->id, 'reason', 'text', false);
$this->approval_status = new object_property($this->table, $this->id, 'approval_status', 'string', false);
$this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false);
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
$this->approved_by = new object_property($this->table, $this->id, 'approved_by', 'int', false);
$this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false);
$this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false);
$this->opened_at = new object_property($this->table, $this->id, 'opened_at', 'string', false);
$this->closed_at = new object_property($this->table, $this->id, 'closed_at', 'string', false);
$this->transcript_text = new object_property($this->table, $this->id, 'transcript_text', 'text', false);
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', 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);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'reason' => (string)$this->reason->value(),
'approval_status' => (string)$this->approval_status->value(),
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
'approved_by' => $this->approved_by->value() === null ? null : (int)$this->approved_by->value(),
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
'expires_at' => (string)$this->expires_at->value(),
'opened_at' => $this->opened_at->value() === null ? null : (string)$this->opened_at->value(),
'closed_at' => $this->closed_at->value() === null ? null : (string)$this->closed_at->value(),
'transcript_text' => $this->transcript_text->value() === null ? null : (string)$this->transcript_text->value(),
'metadata' => (array)($this->metadata_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
];
}
}
@@ -7,6 +7,8 @@ use classes\db;
use classes\email;
use classes\gatewayapi;
use classes\object_property;
use classes\order_bookings_counts_cache;
use classes\order_bookings_list_cache;
use classes\pdf_generator;
use classes\slack;
use Exception;
@@ -290,7 +292,12 @@ class order_bookings_o extends db
public function objectChanged(): void
{
//TODO: Add cache invalidation
try {
order_bookings_list_cache::clearAll();
order_bookings_counts_cache::clearAll();
} catch (\Throwable) {
// Cache invalidation must never break order-booking writes.
}
}
@@ -569,4 +576,68 @@ class order_bookings_o extends db
return count($order_ids);
}
/**
* @param array<int, int|string>|null $departmentIds
* @return array{past:int,current:int,future:int}
*/
public function getPendingBookingCounts(?array $departmentIds = null, ?int $customerNumber = null, ?\DateTimeImmutable $reference = null): array
{
global /** @var db $db */
$db;
$normalizedDepartmentIds = [];
if (is_array($departmentIds)) {
foreach ($departmentIds as $departmentId) {
$normalizedDepartmentId = (int)$departmentId;
if ($normalizedDepartmentId > 0) {
$normalizedDepartmentIds[] = $normalizedDepartmentId;
}
}
$normalizedDepartmentIds = array_values(array_unique($normalizedDepartmentIds));
if ($normalizedDepartmentIds === []) {
return [
'past' => 0,
'current' => 0,
'future' => 0,
];
}
}
$now = $reference ?? new \DateTimeImmutable('now');
$todayStart = $now->setTime(0, 0, 0);
$todayEnd = $now->setTime(23, 59, 59);
$todayStartSql = $db->escape_string($todayStart->format('Y-m-d H:i:s'));
$todayEndSql = $db->escape_string($todayEnd->format('Y-m-d H:i:s'));
$whereClauses = [
'`deleted_at` IS NULL',
"(`order_id` IS NULL OR `order_id` = 0 OR TRIM(CAST(`order_id` AS CHAR)) = '')",
];
if ($normalizedDepartmentIds !== []) {
$whereClauses[] = '`department` IN (' . implode(', ', array_map('intval', $normalizedDepartmentIds)) . ')';
}
if ($customerNumber !== null && $customerNumber > 0) {
$whereClauses[] = '`customer_number` = ' . (int)$customerNumber;
}
$sql = "SELECT
COALESCE(SUM(CASE WHEN `datetime` < '{$todayStartSql}' THEN 1 ELSE 0 END), 0) AS `past`,
COALESCE(SUM(CASE WHEN `datetime` >= '{$todayStartSql}' AND `datetime` <= '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `current`,
COALESCE(SUM(CASE WHEN `datetime` > '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `future`
FROM `order_bookings`
WHERE " . implode(' AND ', $whereClauses);
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
return [
'past' => max(0, (int)($row['past'] ?? 0)),
'current' => max(0, (int)($row['current'] ?? 0)),
'future' => max(0, (int)($row['future'] ?? 0)),
];
}
}
+161 -18
View File
@@ -318,6 +318,16 @@ class orders_o extends db
self::objectChanged();
}
/**
* @throws Exception
*/
public function clearStripeInvoicing(): void
{
self::requireSelected();
$this->stripe_module_orders->clear();
self::objectChanged();
}
public function addArray(array $order_array): orders_o
{
global $db, $response;
@@ -375,10 +385,11 @@ class orders_o extends db
* @param int|null $invoiceCollectionId The invoice collection id, if not set, the default invoice collection id will be used.
* @throws Exception If the order is not selected
*/
public function assignToInvoiceCollection(int $invoiceCollectionId = null): void
public function assignToInvoiceCollection(int $invoiceCollectionId = null, bool $notifyChanges = true): void
{
// If the invoice collection id is not set, get the default invoice collection id
self::requireSelected();
$previousInvoiceCollectionId = (int)$this->invoice_collection_id->value();
// Get the customer
$customer = new users_o();
$customer_id = (int)$this->customer_id->value();
@@ -391,7 +402,16 @@ class orders_o extends db
$invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId();
// Assign the order to the invoice collection
$this->invoice_collection_id->set($invoiceCollectionId);
self::objectChanged();
if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) {
$previousCollection = new collected_order_invoices_o();
$previousCollection->select($previousInvoiceCollectionId);
if ($previousCollection->exists()) {
$previousCollection->objectChanged();
}
}
if ($notifyChanges) {
self::objectChanged();
}
}
/**
@@ -1378,14 +1398,91 @@ class orders_o extends db
public function hasWashCertificateAttached(): bool
{
self::requireSelected();
// Check if the order has a wash certificate attached
$attachments = $this->listAttachments();
foreach ( $attachments as $attachment ) {
if ($attachment->isWashCertificate()) {
return true; // Wash certificate found
return $this->listWashCertificateAttachmentIds() !== [];
}
/**
* @return int[]
* @throws Exception
*/
protected function listWashCertificateAttachmentIds(): array
{
self::requireSelected();
global $db;
$rawObjectType = trim((string)$this->table, '`');
$objectTypes = array_values(array_unique([
$db->escape_string($rawObjectType),
$db->escape_string('`' . $rawObjectType . '`'),
]));
$quotedObjectTypes = "'" . implode("','", $objectTypes) . "'";
$objectId = (int)$this->id;
$sql = "SELECT id, content
FROM object_attachments
WHERE object_type IN ($quotedObjectTypes)
AND object_id = $objectId
AND deleted_at IS NULL";
$result = $db->query($sql);
if (!$result) {
return [];
}
$attachmentIds = [];
while ($row = $db->fetch_assoc($result)) {
$content = json_decode((string)($row['content'] ?? ''), true);
$other = is_array($content) ? ($content['other'] ?? null) : null;
if (is_string($other) && strtolower($other) === 'wash_certificate') {
$attachmentIds[] = (int)($row['id'] ?? 0);
}
}
return false; // No wash certificate product found in the order items
return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0));
}
/**
* @throws Exception
*/
public function regenerateAttachedWashCertificate(): bool
{
self::requireSelected();
if (!$this->hasWashCertificateAttached()) {
return false;
}
$this->removeAttachedWashCertificates();
$this->generateWashCertificate(
$this->getSafetySealValue(),
$this->resolveWashCertificateOperator(),
$this->resolveWashCertificateDate()
);
return $this->hasWashCertificateAttached();
}
/**
* @throws Exception
*/
protected function removeAttachedWashCertificates(): int
{
self::requireSelected();
$attachmentIds = $this->listWashCertificateAttachmentIds();
if ($attachmentIds === []) {
return 0;
}
global $db;
$escapedIds = array_map(static fn(int $id): int => (int)$id, $attachmentIds);
$idList = implode(',', $escapedIds);
$sql = "UPDATE object_attachments
SET deleted_at = NOW()
WHERE id IN ($idList)
AND deleted_at IS NULL";
$db->query($sql);
return count($escapedIds);
}
/**
@@ -1439,6 +1536,42 @@ class orders_o extends db
return self::normalizeSafetySealValue($this->safety_seal->value());
}
/**
* @throws Exception
*/
public function resolveWashCertificateOperator(): ?string
{
self::requireSelected();
$cashierId = (int)$this->cashier_id->value();
if ($cashierId <= 0) {
return null;
}
$cashier = (new users_o())->select($cashierId);
if (!$cashier->exists()) {
return null;
}
$displayName = trim((string)$cashier->display_name->value());
return $displayName === '' ? null : $displayName;
}
/**
* @throws Exception
*/
public function resolveWashCertificateDate(): string
{
self::requireSelected();
$candidate = $this->completed_at->value() ?: $this->created_at->value();
if (is_string($candidate) && trim($candidate) !== '') {
return $candidate;
}
return date('Y-m-d H:i:s');
}
/**
* @throws Exception
*/
@@ -1482,17 +1615,27 @@ class orders_o extends db
if (!$department->exists()) {
throw new Exception('Department not found');
}
// Get branding for the department
$branding = (new branding_o())->select((int)$department->branding->value());
if (!$branding->exists()) {
throw new Exception('Branding not found');
// Branding is optional; fall back to the department values when it is not configured.
$branding = null;
$brandingId = (int)($department->branding->value() ?? 0);
if ($brandingId > 0) {
$selectedBranding = (new branding_o())->select($brandingId);
if ($selectedBranding->exists()) {
$branding = $selectedBranding;
}
}
// Get the customer for the order
$customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
$department_array = $department->asArray();
$department_array['branding'] = $branding->asArray();
$customer_array = $customer->asArray();
$order_array = $this->asArray();
$branding_array = $branding?->asArray() ?? [];
$customer_number = (int)$customer->customer_number->value();
$customer_name = trim((string)$customer->display_name->value());
if ($customer_name === '') {
$customer_name = (string)($customer_number > 0 ? $customer_number : $this->customer_id->value());
}
$customer_address = '-';
// Format the date as 17:35 02-12-2025
$date = ($date instanceof DateTime ? $date : ($date !== null ? new DateTime($date) : new DateTime()));
$date_formatted = ($date instanceof DateTime ? $date->format('d-m-Y') : date('d-m-Y'));
@@ -1526,10 +1669,10 @@ class orders_o extends db
'time' => $time_formatted,
'carried_out_by' => ($operator ?? null),
'department_id' => $department->id,
'department_name' => $department_array['branding']['name'] ?: $department_array['name'],
'department_address' => $department_array['branding']['address'] ?: $department_array['description'],
'customer_name' => $customer->getCustomerName($customer_array['customer_number']),
'customer_address' => $customer->getCustomerEcocomicData($customer_array['customer_number'])->economic_customer->address ?: '-',
'department_name' => ($branding_array['name'] ?? null) ?: $department_array['name'],
'department_address' => ($branding_array['address'] ?? null) ?: $department_array['description'],
'customer_name' => $customer_name,
'customer_address' => $customer_address,
'wash_type' => 'ORDER_WASH'
])
->getHtml()
@@ -93,4 +93,19 @@ class stripe_module_orders_o extends db
return (new stripe())->invoice->retrieve($this->invoice_id->value());
}
}
public function clear(): void
{
if (!$this->exists()) {
return;
}
$this->delete();
}
public function deleteForOrder(int $order_id): void
{
$record = (new self())->select($order_id);
$record->clear();
}
}
+13 -1
View File
@@ -2195,6 +2195,17 @@ paths:
two_factor_enabled:
type: boolean
description: Indicates if 2FA is enabled for this account
runtime_config:
type: object
properties:
economic:
type: object
properties:
transaction_draft_customer_number:
type: integer
nullable: true
additionalProperties: false
additionalProperties: true
'400':
$ref: '#/components/responses/BadRequest'
'401':
@@ -12041,12 +12052,13 @@ components:
type: object
properties:
module: { type: string, enum: [economic] }
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] }
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] }
type: { type: string, enum: [string, int] }
value:
oneOf:
- type: string
- type: integer
nullable: true
required: [module, variable, type, value]
RecaptchaConfigEntry:
@@ -0,0 +1,770 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This agent must run from the CLI.\n");
exit(1);
}
final class AgentConfig
{
public function __construct(public array $data, public string $path)
{
}
public static function load(string $path): self
{
if (!is_file($path)) {
throw new RuntimeException('Missing config file: ' . $path);
}
$decoded = json_decode((string)file_get_contents($path), true);
if (!is_array($decoded)) {
throw new RuntimeException('Invalid config file: ' . $path);
}
return new self($decoded, $path);
}
public function get(string $key, mixed $default = null): mixed
{
return array_key_exists($key, $this->data) ? $this->data[$key] : $default;
}
public function set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function save(): void
{
file_put_contents($this->path, json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
}
final class HttpJsonClient
{
public function __construct(private readonly string $baseUrl)
{
}
public function post(string $path, array $payload, int $timeoutSeconds = 30): ?array
{
$response = $this->requestJson(
'POST',
rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/'),
$payload,
$timeoutSeconds
);
return $response['decoded'];
}
public function getJson(string $url, int $timeoutSeconds = 10): array
{
$response = $this->requestJson('GET', $url, null, $timeoutSeconds);
return $response['decoded'] ?? [];
}
public function download(string $url, int $timeoutSeconds = 60): string
{
$response = $this->requestRaw('GET', $url, null, $timeoutSeconds, ['Accept: */*']);
return $response['body'];
}
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
{
$headers = ['Accept: application/json'];
if ($payload !== null) {
$headers[] = 'Content-Type: application/json';
}
$response = $this->requestRaw($method, $url, $payload, $timeoutSeconds, $headers);
$decoded = json_decode($response['body'], true);
if (!is_array($decoded)) {
throw new RuntimeException('Invalid JSON response from ' . $method . ' ' . $url);
}
$response['decoded'] = $decoded;
return $response;
}
private function requestRaw(
string $method,
string $url,
?array $payload,
int $timeoutSeconds,
array $headers
): array {
$responseHeaders = [];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => min(10, $timeoutSeconds),
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_HEADERFUNCTION => static function ($curl, string $headerLine) use (&$responseHeaders): int {
$trimmed = trim($headerLine);
if ($trimmed !== '') {
$responseHeaders[] = $trimmed;
}
return strlen($headerLine);
},
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException(sprintf('%s %s failed: %s', $method, $url, $error !== '' ? $error : 'unknown curl error'));
}
if ($status >= 400) {
$preview = substr(preg_replace('/\s+/', ' ', (string)$raw) ?? '', 0, 240);
$headerPreview = implode('; ', array_slice($responseHeaders, 0, 8));
throw new RuntimeException(sprintf(
'%s %s returned HTTP %d. Headers: %s. Body preview: %s',
$method,
$url,
$status,
$headerPreview !== '' ? $headerPreview : 'none',
$preview !== '' ? $preview : 'empty'
));
}
return [
'status' => $status,
'body' => (string)$raw,
'headers' => $responseHeaders,
];
}
}
final class Logger
{
public function __construct(private readonly string $logFile)
{
}
public function info(string $message): void
{
$this->write('INFO', $message);
}
public function warning(string $message): void
{
$this->write('WARNING', $message);
}
public function error(string $message): void
{
$this->write('ERROR', $message);
}
private function write(string $level, string $message): void
{
$line = '[' . date('c') . "] {$level} {$message}\n";
file_put_contents($this->logFile, $line, FILE_APPEND);
fwrite($level === 'ERROR' ? STDERR : STDOUT, $line);
}
}
final class TruckwashEdgeAgent
{
private AgentConfig $config;
private HttpJsonClient $http;
private Logger $logger;
private string $installDir;
private string $runtimeDir;
private string $statePath;
private string $lastOperationSnapshotPath;
private string $lastHeartbeatMarkerPath;
private int $lastHeartbeatAt = 0;
private string $agentInstanceId;
public function __construct(string $configPath)
{
$this->config = AgentConfig::load($configPath);
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
$this->installDir = rtrim((string)$this->config->get('installDir', dirname($configPath)), DIRECTORY_SEPARATOR);
$this->runtimeDir = $this->installDir . DIRECTORY_SEPARATOR . 'runtime';
@mkdir($this->runtimeDir, 0777, true);
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
$this->agentInstanceId = $this->ensureAgentInstanceId();
}
public function run(): void
{
$this->logger->info('Truckwash PHP edge agent starting.');
$this->recoverPreviousOperationState();
while (true) {
try {
$this->ensureClaimed();
$this->heartbeat();
$processedManagementOperation = $this->processManagementOperation();
if (!$processedManagementOperation) {
$this->processCommandQueue();
}
} catch (Throwable $throwable) {
$this->logger->error($throwable->getMessage());
sleep(2);
}
usleep(250000);
}
}
private function ensureClaimed(): void
{
if ($this->config->get('gatewayId') && $this->config->get('agentToken')) {
return;
}
$response = $this->http->post('/edge-agent/claim', [
'token' => (string)$this->config->get('installToken'),
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
'metadata' => [
'runtime' => 'php-cli',
'php_version' => PHP_VERSION,
'agent_instance_id' => $this->agentInstanceId,
],
]);
$payload = $response['data'] ?? [];
$gateway = $payload['gateway'] ?? [];
$installedVersion = (string)$this->config->get('installedVersion', 'php-agent-v1');
$this->config->set('gatewayId', $gateway['id'] ?? null);
$this->config->set('agentToken', $payload['agent_token'] ?? null);
$this->config->set('installedVersion', $installedVersion);
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
$this->config->set('agentInstanceId', $this->agentInstanceId);
$this->config->save();
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
}
private function heartbeat(bool $force = false, array $metadata = []): void
{
$interval = (int)$this->config->get('heartbeatIntervalSeconds', 15);
if (!$force && (time() - $this->lastHeartbeatAt) < max(5, $interval)) {
return;
}
$gatewayId = (int)$this->config->get('gatewayId');
if ($gatewayId <= 0) {
return;
}
$operationState = $this->readOperationState();
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [
'agent_token' => (string)$this->config->get('agentToken'),
'status' => 'ONLINE',
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
'target_version' => (string)$this->config->get('targetVersion', $this->config->get('installedVersion', 'php-agent-v1')),
'metadata' => array_merge([
'agent_instance_id' => $this->agentInstanceId,
'current_operation' => $operationState,
'system_metrics' => [
'memory_usage_bytes' => memory_get_usage(true),
'memory_peak_bytes' => memory_get_peak_usage(true),
'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : [],
],
], $metadata),
]);
$this->lastHeartbeatAt = time();
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
'gateway_id' => $gatewayId,
'at' => date('c'),
'agent_instance_id' => $this->agentInstanceId,
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
private function processCommandQueue(): void
{
$gatewayId = (int)$this->config->get('gatewayId');
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [
'agent_token' => (string)$this->config->get('agentToken'),
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
]);
$command = $response['data'] ?? null;
if (!is_array($command) || empty($command['id'])) {
return;
}
$jobId = (int)$command['id'];
$type = (string)($command['command_type'] ?? $command['commandType'] ?? '');
$request = is_array($command['payload'] ?? null) ? (array)$command['payload'] : [];
try {
$result = match ($type) {
'DISCOVER_SHELLY' => $this->runDiscovery(),
'GET_RELAY_STATUS' => $this->readRelayStatus($request),
'SET_RELAY_STATE' => $this->switchRelay($request),
default => throw new RuntimeException('Unsupported command type: ' . $type),
};
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
'agent_token' => (string)$this->config->get('agentToken'),
'ok' => true,
'result' => $result,
]);
} catch (Throwable $throwable) {
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
'agent_token' => (string)$this->config->get('agentToken'),
'ok' => false,
'error' => $throwable->getMessage(),
]);
}
}
private function processManagementOperation(): bool
{
$gatewayId = (int)$this->config->get('gatewayId');
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/next', [
'agent_token' => (string)$this->config->get('agentToken'),
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
'agent_instance_id' => $this->agentInstanceId,
]);
$operation = $response['data'] ?? null;
if (!is_array($operation) || empty($operation['id'])) {
return false;
}
$operationId = (int)$operation['id'];
$type = (string)($operation['type'] ?? '');
$request = is_array($operation['request'] ?? null) ? (array)$operation['request'] : [];
$this->persistOperationState([
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
'type' => $type,
'status' => 'IN_PROGRESS',
'stage' => 'claimed',
'progress' => 5,
'agent_instance_id' => $this->agentInstanceId,
'started_at' => date('c'),
'request' => $request,
]);
$this->emitOperationProgress(
$gatewayId,
$operationId,
'OPERATION_AGENT_STARTED',
'PHP edge agent started processing the operation',
10,
['type' => $type, 'agent_instance_id' => $this->agentInstanceId],
'starting'
);
try {
$result = match ($type) {
'DISCOVERY' => $this->runManagementDiscovery($gatewayId, $operationId, $request),
'UPDATE' => $this->runUpdate($gatewayId, $operationId, $request),
'UNINSTALL' => $this->runUninstall($gatewayId, $operationId, $request),
default => throw new RuntimeException('Unsupported operation type: ' . $type),
};
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
'agent_token' => (string)$this->config->get('agentToken'),
'ok' => true,
'result' => $result,
]);
$this->snapshotOperationState('COMPLETED', ['result' => $result]);
$this->clearOperationState();
} catch (Throwable $throwable) {
$errorCode = $this->classifyManagementError($throwable, $type);
$this->postOperationEvent($gatewayId, $operationId, [
'level' => 'ERROR',
'code' => $errorCode,
'message' => $throwable->getMessage(),
'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId],
]);
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
'agent_token' => (string)$this->config->get('agentToken'),
'ok' => false,
'error_code' => $errorCode,
'error_message' => $throwable->getMessage(),
]);
$this->snapshotOperationState('FAILED', [
'error_code' => $errorCode,
'error_message' => $throwable->getMessage(),
]);
$this->clearOperationState();
}
return true;
}
private function postOperationEvent(int $gatewayId, int $operationId, array $payload): void
{
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events', [
'agent_token' => (string)$this->config->get('agentToken'),
'level' => (string)($payload['level'] ?? 'INFO'),
'code' => $payload['code'] ?? null,
'message' => (string)($payload['message'] ?? 'Operation event'),
'context' => is_array($payload['context'] ?? null) ? (array)$payload['context'] : [],
]);
}
private function emitOperationProgress(
int $gatewayId,
int $operationId,
string $code,
string $message,
int $progress,
array $context = [],
?string $stage = null
): void {
$operationState = $this->readOperationState();
if ($operationState !== null) {
$operationState['progress'] = max(0, min(100, $progress));
$operationState['stage'] = $stage ?? ($operationState['stage'] ?? 'running');
$operationState['updated_at'] = date('c');
$this->persistOperationState($operationState);
}
$this->heartbeat(true, [
'current_operation' => $this->readOperationState(),
]);
$this->postOperationEvent($gatewayId, $operationId, [
'level' => 'INFO',
'code' => $code,
'message' => $message,
'context' => array_merge($context, [
'progress' => max(0, min(100, $progress)),
'agent_instance_id' => $this->agentInstanceId,
]),
]);
}
private function runDiscovery(): array
{
return ['inventory' => []];
}
private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array
{
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COLLECTING', 'Collecting local gateway inventory', 35, [], 'discovering');
$hostname = gethostname() ?: 'truckwash-edge';
$fallbackInventory = [[
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
'local_ip' => gethostbyname($hostname),
'model' => 'TruckWash Edge Gateway',
'channel_count' => 1,
'online' => true,
'capabilities' => [
'local_discovery' => true,
'relay_commands' => true,
'gateway_management_v2' => true,
],
'metadata' => [
'hostname' => $hostname,
'runtime' => 'php-cli',
'php_version' => PHP_VERSION,
],
]];
$inventory = isset($request['inventory']) && is_array($request['inventory']) && $request['inventory'] !== []
? (array)$request['inventory']
: $fallbackInventory;
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COMPLETED', 'Inventory collected', 90, [
'device_count' => count($inventory),
], 'finishing');
return ['inventory' => $inventory];
}
private function runUpdate(int $gatewayId, int $operationId, array $request): array
{
$targetVersion = trim((string)($request['target_version'] ?? $request['targetVersion'] ?? ''));
if ($targetVersion === '') {
throw new RuntimeException('Update request is missing target version');
}
$agentArtifactUrl = trim((string)($request['artifactUrl'] ?? ''));
$serviceUnitUrl = trim((string)($request['serviceUnitUrl'] ?? ''));
$agentArtifactSha = trim((string)($request['artifactSha256'] ?? ''));
$serviceUnitSha = trim((string)($request['serviceUnitSha256'] ?? ''));
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated update request', 20, [
'target_version' => $targetVersion,
], 'validating');
$changedFiles = [];
if ($agentArtifactUrl !== '') {
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_AGENT', 'Downloading PHP edge agent artifact', 40, [
'url' => $agentArtifactUrl,
], 'downloading-agent');
$changedFiles[] = $this->downloadToFileAtomic(
$agentArtifactUrl,
$this->installDir . DIRECTORY_SEPARATOR . 'agent.php',
$agentArtifactSha
);
}
if ($serviceUnitUrl !== '') {
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_SERVICE', 'Downloading service unit', 65, [
'url' => $serviceUnitUrl,
], 'downloading-service');
$changedFiles[] = $this->downloadToFileAtomic(
$serviceUnitUrl,
$this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service',
$serviceUnitSha
);
}
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_WRITE_CONFIG', 'Writing staged update metadata', 85, [], 'writing-config');
$this->config->set('installedVersion', $targetVersion);
$this->config->set('targetVersion', $targetVersion);
$this->config->set('lastStagedUpdate', [
'target_version' => $targetVersion,
'staged_at' => date('c'),
'changed_files' => $changedFiles,
]);
$this->config->save();
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Artifacts staged successfully', 95, [
'changed_files' => $changedFiles,
], 'staged');
return [
'installed_version' => $targetVersion,
'target_version' => $targetVersion,
'restart_required' => true,
'service_name' => (string)$this->config->get('serviceName', 'truckwash-edge-agent.service'),
'changed_files' => $changedFiles,
'agent_instance_id' => $this->agentInstanceId,
];
}
private function runUninstall(int $gatewayId, int $operationId, array $request): array
{
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_PREPARE', 'Preparing uninstall manifest', 40, [], 'preparing-uninstall');
$manifestPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall-plan.json';
$manifest = [
'service_name' => (string)($request['service_name'] ?? $this->config->get('serviceName', 'truckwash-edge-agent.service')),
'install_dir' => (string)($request['install_dir'] ?? $this->installDir),
'generated_at' => date('c'),
'agent_instance_id' => $this->agentInstanceId,
];
file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall.flag', date('c') . PHP_EOL);
$this->config->set('pendingUninstall', $manifest);
$this->config->save();
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_READY', 'Gateway marked for controlled uninstall', 90, [
'manifest_path' => $manifestPath,
], 'uninstall-ready');
return [
'uninstalled' => true,
'service_name' => $manifest['service_name'],
'install_dir' => $manifest['install_dir'],
'manual_cleanup_required' => true,
'cleanup_manifest_path' => $manifestPath,
];
}
private function readRelayStatus(array $request): array
{
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
$channel = (int)($request['channel'] ?? 0);
return $this->fetchShellyState($localIp, $channel);
}
private function switchRelay(array $request): array
{
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
$channel = (int)($request['channel'] ?? 0);
$on = (bool)($request['on'] ?? false);
$rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false');
try {
$this->http->getJson($rpcUrl, 8);
} catch (Throwable) {
$legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off');
$this->http->getJson($legacyUrl, 8);
}
return $this->fetchShellyState($localIp, $channel);
}
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
{
$directory = dirname($path);
if (!is_dir($directory)) {
@mkdir($directory, 0777, true);
}
$temporaryPath = $path . '.download';
$backupPath = $path . '.bak';
$raw = $this->http->download($url, 60);
file_put_contents($temporaryPath, $raw);
if ($expectedSha256 !== '') {
$actualSha = hash_file('sha256', $temporaryPath);
if (!is_string($actualSha) || !hash_equals(strtolower($expectedSha256), strtolower($actualSha))) {
@unlink($temporaryPath);
throw new RuntimeException('Artifact checksum mismatch for ' . basename($path));
}
}
if (is_file($backupPath)) {
@unlink($backupPath);
}
if (is_file($path) && !@rename($path, $backupPath)) {
@unlink($temporaryPath);
throw new RuntimeException('Could not create backup for ' . $path);
}
if (!@rename($temporaryPath, $path)) {
if (is_file($backupPath)) {
@rename($backupPath, $path);
}
@unlink($temporaryPath);
throw new RuntimeException('Could not promote downloaded artifact for ' . $path);
}
return [
'path' => $path,
'url' => $url,
'backup_path' => is_file($backupPath) ? $backupPath : null,
'sha256' => hash_file('sha256', $path),
];
}
private function classifyManagementError(Throwable $throwable, string $type): string
{
$message = strtolower(trim($throwable->getMessage()));
if (str_contains($message, 'token') || str_contains($message, 'credential')) {
return 'EDGE_GATEWAY_INVALID_TOKEN';
}
if (str_contains($message, 'timeout') || str_contains($message, 'http 5')) {
return 'EDGE_GATEWAY_OPERATION_TIMEOUT';
}
if ($type === 'UPDATE' && (str_contains($message, 'version') || str_contains($message, 'checksum'))) {
return 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
}
return 'EDGE_GATEWAY_VALIDATION_FAILED';
}
private function fetchShellyState(string $localIp, int $channel): array
{
if ($localIp === '') {
throw new RuntimeException('Missing Shelly IP address');
}
try {
$payload = $this->http->getJson(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel), 8);
return [
'online' => true,
'on' => (bool)($payload['output'] ?? false),
'output' => (bool)($payload['output'] ?? false),
'raw' => $payload,
];
} catch (Throwable) {
$payload = $this->http->getJson(sprintf('http://%s/relay/%d', $localIp, $channel), 8);
return [
'online' => true,
'on' => (bool)($payload['ison'] ?? false),
'output' => (bool)($payload['ison'] ?? false),
'raw' => $payload,
];
}
}
private function ensureAgentInstanceId(): string
{
$configured = trim((string)$this->config->get('agentInstanceId', ''));
if ($configured !== '') {
return substr($configured, 0, 128);
}
$generated = sprintf(
'%s-%s',
preg_replace('/[^A-Za-z0-9\-]+/', '-', gethostname() ?: 'truckwash-edge') ?: 'truckwash-edge',
substr(bin2hex(random_bytes(6)), 0, 12)
);
$this->config->set('agentInstanceId', $generated);
$this->config->save();
return $generated;
}
private function recoverPreviousOperationState(): void
{
$state = $this->readOperationState();
if ($state === null) {
return;
}
$this->logger->warning(sprintf(
'Recovered previous unfinished operation snapshot: operation %s (%s) last stage %s.',
(string)($state['operation_id'] ?? 'unknown'),
(string)($state['type'] ?? 'unknown'),
(string)($state['stage'] ?? 'unknown')
));
}
private function persistOperationState(array $state): void
{
file_put_contents($this->statePath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
private function snapshotOperationState(string $status, array $extra = []): void
{
$state = $this->readOperationState() ?? [];
$state['status'] = $status;
$state['finished_at'] = date('c');
$state = array_merge($state, $extra);
file_put_contents($this->lastOperationSnapshotPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
private function clearOperationState(): void
{
if (is_file($this->statePath)) {
@unlink($this->statePath);
}
}
private function readOperationState(): ?array
{
if (!is_file($this->statePath)) {
return null;
}
$decoded = json_decode((string)file_get_contents($this->statePath), true);
return is_array($decoded) ? $decoded : null;
}
}
$configPath = null;
foreach ($argv as $index => $argument) {
if ($argument === '--config' && isset($argv[$index + 1])) {
$configPath = $argv[$index + 1];
break;
}
}
if (!is_string($configPath) || trim($configPath) === '') {
fwrite(STDERR, "Usage: agent.php --config /path/to/config.json\n");
exit(1);
}
(new TruckwashEdgeAgent($configPath))->run();
@@ -0,0 +1,14 @@
[Unit]
Description=TruckWash Edge Agent
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/truckwash-edge-agent
ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
+21
View File
@@ -116,6 +116,12 @@ class authRoute
try {
$cached = redis->get_auth_session($token);
if (is_array($cached)) {
$cached = $this->appendRuntimeConfig($cached);
try {
redis->cache_auth_session($token, $cached, 60);
} catch (\Throwable $e) {
// Best-effort caching only
}
$response->success($cached);
}
} catch (\Throwable $e) {
@@ -131,6 +137,7 @@ class authRoute
$user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray();
$user_data['two_factor_enabled'] = $user->isTwoFactorEnabled();
$user_data = $this->appendRuntimeConfig($user_data);
// Cache the session payload briefly to reduce DB load on hot paths
try {
@@ -810,4 +817,18 @@ class authRoute
(new logs_o())->add('auth', 'global', 0, 0, $action, $message);
}
private function appendRuntimeConfig(array $payload): array
{
$payload['runtime_config'] = array_replace_recursive(
is_array($payload['runtime_config'] ?? null) ? $payload['runtime_config'] : [],
[
'economic' => [
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
],
]
);
return $payload;
}
}
@@ -5,6 +5,7 @@ namespace routes;
use classes\authentication;
use classes\department_outside_hours_statistics_service;
use classes\workfeed;
use classes\workfeed_shift_time_resolver;
use customers\economicCustomers;
use DateInterval;
use DateTime;
@@ -1654,53 +1655,7 @@ class departmentDailyReportsRoute
private function calculateShiftOvertimeHoursInRange(array $record, DateTime $range_start, DateTime $range_end_exclusive): float
{
$shift_start = $this->firstShiftDateTimeFromPaths($record, [
'actualStart',
'actualStartTime',
'clockIn',
'clockInTime',
'start',
'startTime',
'from',
'approval.originalStart',
]);
$scheduled_end = $this->lastShiftDateTimeFromPaths($record, [
'end',
'endTime',
'to',
]);
$effective_end = $this->lastShiftDateTimeFromPaths($record, [
'actualEnd',
'actualEndTime',
'clockOut',
'clockOutTime',
'end',
'endTime',
'to',
'approval.originalEnd',
'approval.end',
]);
if ($shift_start === null || $scheduled_end === null || $effective_end === null) {
return 0.0;
}
$effective_end = $this->resolveEffectiveShiftEnd($record, $shift_start, $effective_end);
$scheduled_end_ts = $scheduled_end->getTimestamp();
$effective_end_ts = $effective_end->getTimestamp();
if ($effective_end_ts <= $scheduled_end_ts) {
return 0.0;
}
$overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
$overtime_end_ts = min($effective_end_ts, $range_end_exclusive->getTimestamp());
if ($overtime_end_ts <= $overtime_start_ts) {
return 0.0;
}
return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
return workfeed_shift_time_resolver::calculateOvertimeHoursInRange($record, $range_start, $range_end_exclusive);
}
/**
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\economic;
use classes\economic_transfer_queue;
use classes\response;
use classes\router;
@@ -45,6 +46,11 @@ class economicInvoiceRoute
if (!$order->exists()) {
$response->error('Order not found', 404);
}
try {
$this->assertOrderCanBeExportedToEconomic((int)$order_id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
if (!$this->isEconomicTransferQueueAvailable()) {
try {
@@ -158,6 +164,11 @@ class economicInvoiceRoute
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
try {
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId((int)$order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
$response->error('No economic invoice draft found', 404);
@@ -365,6 +376,7 @@ class economicInvoiceRoute
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
$economic_invoice_draft = new economic_invoice_draft_mo();
@@ -449,6 +461,7 @@ class economicInvoiceRoute
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
@@ -622,4 +635,17 @@ class economicInvoiceRoute
$response->error('Economic transfer queue is unavailable in this deployment', 503);
}
}
/**
* @throws \Exception
*/
private function assertOrderCanBeExportedToEconomic(int $order_id): void
{
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
}
}
+338 -465
View File
@@ -3,9 +3,14 @@
namespace routes;
use classes\authentication;
use classes\edge_gateway_install_service;
use classes\edge_gateway_manager;
use classes\edge_gateway_operation_exception;
use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
use classes\edge_gateway_view_service;
use classes\response;
use objects\logs_o;
use Exception;
use traits\route_t;
class edgeGatewaysRoute
@@ -14,244 +19,223 @@ class edgeGatewaysRoute
public function run(): void
{
$this->get('/edge-gateways', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
if ($departmentId !== null && $departmentId > 0) {
$this->requireDepartmentAccess((int)$departmentId);
}
$response->success((new edge_gateway_manager())->listGateways($departmentId));
}, [
$this->get('/edge-gateways', fn() => $this->handleListGateways(), [
'modules_shelly_config' => 'Manage department edge gateways for local Shelly control',
]);
$this->get('/edge-gateways/{id}', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$response->success($gateway);
}, [
$this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
'modules_shelly_config' => 'View department edge gateway detail',
]);
$this->post('/edge-gateways/install-token', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['department_id']);
$departmentId = (int)self::getParameter('department_id');
self::requireParameterIntPositive($departmentId, 'department_id');
$this->requireDepartmentAccess($departmentId);
$user = (new authentication())->get_user();
$response->success(
(new edge_gateway_manager())->createInstallToken(
$departmentId,
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
$user ? (int)$user->id : null
),
201
);
}, [
$this->put('/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
'modules_shelly_config' => 'Update edge gateway metadata and primary assignment',
]);
$this->get('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationsList(), [
'modules_shelly_config' => 'List edge gateway operations',
]);
$this->post('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
'modules_shelly_config' => 'Queue an edge gateway operation',
]);
$this->get('/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
'modules_shelly_config' => 'List edge gateway operation events',
]);
$this->post('/edge-gateways/{id}/rotate-credentials', fn() => $this->handleGatewayCredentialRotate(), [
'modules_shelly_config' => 'Rotate edge gateway credentials',
]);
$this->post('/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
]);
$this->post('/edge-gateways/{id}/discovery', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->queueDiscovery($gatewayId, $user ? (int)$user->id : null));
}, [
'modules_shelly_config' => 'Trigger a Shelly LAN discovery job on an edge gateway',
$this->post('/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
'modules_shelly_config' => 'Queue Shelly discovery through the local edge gateway',
]);
$this->put('/edge-gateways/{id}/bindings', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['bindings']);
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->setRelayBindings(
$gatewayId,
(array)self::getParameter('bindings'),
$user ? (int)$user->id : null
));
}, [
$this->put('/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [
'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway',
]);
$this->post('/edge-gateways/{id}/update-jobs', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['target_version']);
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->queueUpdate(
$gatewayId,
(string)self::getParameter('target_version'),
self::isParametersSet(['release_channel']) ? (string)self::getParameter('release_channel') : edge_gateway_manager::DEFAULT_RELEASE_CHANNEL,
$user ? (int)$user->id : null
), 201);
}, [
'modules_shelly_config' => 'Queue an automatic edge gateway update',
]);
$this->post('/edge-gateways/{id}/uninstall', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->queueUninstall($gatewayId, $user ? (int)$user->id : null), 202);
}, [
'modules_shelly_config' => 'Queue an edge gateway uninstall on the Raspberry Pi',
]);
$this->post('/edge-gateways/{id}/shell-sessions', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['reason']);
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
self::requireMinLength('reason', 5);
$user = (new authentication())->get_user();
$response->success($manager->createShellSessionRequest(
$gatewayId,
(string)self::getParameter('reason'),
[
'cols' => self::isParametersSet(['cols']) ? (int)self::getParameter('cols') : null,
'rows' => self::isParametersSet(['rows']) ? (int)self::getParameter('rows') : null,
],
$user ? (int)$user->id : null
), 201);
}, [
'modules_shelly_config' => 'Approve a break-glass root shell on an edge gateway',
]);
$this->get('/edge-gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleShellSessionEvents(), [
'modules_shelly_config' => 'Poll root shell events for an edge gateway',
]);
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/input', fn() => $this->handleShellSessionInput(), [
'modules_shelly_config' => 'Send terminal input to an edge gateway root shell',
]);
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/resize', fn() => $this->handleShellSessionResize(), [
'modules_shelly_config' => 'Resize an edge gateway root shell terminal',
]);
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/close', fn() => $this->handleShellSessionClose(), [
'modules_shelly_config' => 'Close an approved edge gateway root shell session',
]);
$this->post('/edge-gateways/{id}/rotate-credentials', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->rotateGatewayCredentials($gatewayId, $user ? (int)$user->id : null));
}, [
'modules_shelly_config' => 'Rotate edge gateway agent credentials',
]);
$this->delete('/edge-gateways/{id}', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->deleteGateway($gatewayId, $user ? (int)$user->id : null));
}, [
$this->delete('/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [
'modules_shelly_config' => 'Delete an edge gateway registration',
]);
$this->post('/departments/{id}/gateway-cutover', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$departmentId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($departmentId, 'id');
$this->requireDepartmentAccess($departmentId);
self::requireParameters(['transport_mode']);
$user = (new authentication())->get_user();
$response->success((new edge_gateway_manager())->setDepartmentTransportMode(
$departmentId,
(string)self::getParameter('transport_mode'),
$user ? (int)$user->id : null
));
}, [
$this->post('/departments/{id}/gateway-cutover', fn() => $this->handleDepartmentCutover(), [
'modules_shelly_config' => 'Cut a department over from Shelly cloud to local edge gateways',
]);
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/agent.mjs', fn() => $this->renderAgentArtifact('agent.mjs', 'application/javascript; charset=utf-8'));
$this->get('/edge-agent/artifacts/package.json', fn() => $this->renderAgentArtifact('package.json', 'application/json; charset=utf-8'));
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
$this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service'));
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
$this->post('/edge-agent/gateways/{id}/operations/next', fn() => $this->handleAgentOperationNext());
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/events', fn() => $this->handleAgentOperationEvent());
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
$this->post('/edge-agent/gateways/{id}/shell-actions/poll', fn() => $this->handleAgentShellActionPoll());
$this->post('/edge-agent/gateways/{id}/shell-actions/{actionId}/result', fn() => $this->handleAgentShellActionResult());
$this->post('/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleAgentShellEvents());
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellValidate());
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
$this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose());
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
}
private function handleListGateways(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
$view = trim((string)$this->fromQuery('view'));
if ($departmentId !== null && $departmentId > 0) {
$this->requireDepartmentAccess($departmentId);
}
$gateways = $this->views()->listGateways($departmentId, $view !== 'summary');
$response->add_meta('fleet_usage', $this->views()->buildFleetUsageStatistics($departmentId, $gateways));
$response->success($gateways);
}
private function handleGatewayDetail(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$response->success($this->requireGatewayAccess((int)$this->fromRoute('id')));
}
private function handleGatewayUpdate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['label', 'is_primary']);
self::requireType(self::getParameter('label'), self::TYPE_STRING());
self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->registry()->updateGatewayMetadata($gatewayId, [
'label' => (string)self::getParameter('label'),
'is_primary' => (bool)self::getParameter('is_primary'),
], $this->actorUserId()));
}
private function handleGatewayOperationsList(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->operations()->listOperations($gatewayId));
}
private function handleGatewayOperationCreate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['type', 'request']);
self::requireType(self::getParameter('type'), self::TYPE_STRING());
self::requireType(self::getParameter('request'), self::TYPE_ARRAY());
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
try {
$operation = $this->operations()->queueOperation(
$gatewayId,
(string)self::getParameter('type'),
(array)self::getParameter('request'),
$this->actorUserId()
);
$response->success([
'operation' => $operation,
'gateway' => $this->views()->getGateway($gatewayId),
], 201);
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleGatewayOperationEvents(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$this->requireGatewayAccess($gatewayId);
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
}
private function handleGatewayCredentialRotate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->operations()->rotateCredentials($gatewayId, $this->actorUserId()));
}
private function handleInstallTokenCreate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['department_id']);
$departmentId = (int)self::getParameter('department_id');
self::requireParameterIntPositive($departmentId, 'department_id');
$this->requireDepartmentAccess($departmentId);
$response->success(
$this->registry()->createInstallToken(
$departmentId,
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
$this->actorUserId()
),
201
);
}
private function handleGatewayDiscovery(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$this->operations()->queueDiscoveryOperation($gatewayId, $this->actorUserId());
$response->success($this->views()->getGateway($gatewayId));
}
private function handleBindingsUpdate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['bindings']);
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$this->registry()->setRelayBindings($gatewayId, (array)self::getParameter('bindings'), $this->actorUserId());
$response->success($this->views()->getGateway($gatewayId));
}
private function handleGatewayDelete(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->registry()->deleteGateway($gatewayId, $this->actorUserId()));
}
private function handleDepartmentCutover(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['transport_mode']);
$departmentId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($departmentId, 'id');
$this->requireDepartmentAccess($departmentId);
$response->success($this->registry()->setDepartmentTransportMode($departmentId, (string)self::getParameter('transport_mode'), $this->actorUserId()));
}
private function renderInstallScript(): void
{
$manager = new edge_gateway_manager();
$token = trim((string)$this->fromQuery('token'));
if ($token === '') {
http_response_code(400);
@@ -260,7 +244,7 @@ class edgeGatewaysRoute
}
header('Content-Type: text/x-shellscript; charset=utf-8');
echo $manager->buildInstallScript($token);
echo $this->install()->buildInstallScript($token);
exit;
}
@@ -272,21 +256,20 @@ class edgeGatewaysRoute
$response->error('Missing token', 400);
}
$response->success((new edge_gateway_manager())->verifyInstallToken($token));
$response->success($this->install()->verifyInstallToken($token));
}
private function renderAgentArtifact(string $fileName, string $contentType): void
private function renderArtifact(string $fileName): void
{
$artifactPath = dirname(WD, 3) . '/services/edge-agent/dist/' . $fileName;
if (!is_file($artifactPath)) {
try {
header('Content-Type: ' . $this->install()->contentType($fileName));
echo $this->install()->readArtifact($fileName);
exit;
} catch (Exception $exception) {
http_response_code(404);
echo 'Missing edge agent artifact';
echo $exception->getMessage();
exit;
}
header('Content-Type: ' . $contentType);
echo file_get_contents($artifactPath);
exit;
}
private function handleAgentClaim(): void
@@ -294,40 +277,103 @@ class edgeGatewaysRoute
global /** @var response $response */ $response;
self::requireParameters(['token']);
$payload = self::getParametersAsArray();
$response->success((new edge_gateway_manager())->claimGateway(
(string)$payload['token'],
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
(array)($payload['metadata'] ?? [])
), 201);
$response->success(
$this->registry()->claimGateway(
(string)$payload['token'],
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
),
201
);
}
private function handleAgentHeartbeat(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
$response->success($this->registry()->recordHeartbeat($gatewayId, $this->requireAgentToken($payload), $payload));
}
private function handleAgentOperationNext(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
try {
$response->success($this->operations()->claimNextOperation(
$gatewayId,
$this->requireAgentToken($payload),
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS,
isset($payload['agent_instance_id']) ? (string)$payload['agent_instance_id'] : null
));
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleAgentOperationEvent(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$payload = self::getParametersAsArray();
try {
$response->success($this->operations()->appendAgentOperationEvent(
$gatewayId,
$operationId,
$this->requireAgentToken($payload),
$payload
));
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleAgentOperationComplete(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$payload = self::getParametersAsArray();
try {
$response->success($this->operations()->completeAgentOperation(
$gatewayId,
$operationId,
$this->requireAgentToken($payload),
$payload
));
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
$response->success((new edge_gateway_manager())->recordHeartbeat($gatewayId, $token, $payload));
}
private function handleAgentCommandPoll(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS;
$response->success((new edge_gateway_manager())->pollCommand($gatewayId, $token, $waitSeconds));
$response->success($this->manager()->pollCommand(
$gatewayId,
$this->requireAgentToken($payload),
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS
));
}
private function handleAgentCommandResult(): void
@@ -335,256 +381,83 @@ class edgeGatewaysRoute
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$jobId = (int)$this->fromRoute('jobId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($jobId, 'jobId');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$response->success((new edge_gateway_manager())->submitCommandResult(
$response->success($this->manager()->submitCommandResult(
$gatewayId,
$jobId,
$token,
$this->requireAgentToken($payload),
(bool)($payload['ok'] ?? false),
isset($payload['payload']) && is_array($payload['payload']) ? (array)$payload['payload'] : [],
isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [],
isset($payload['error']) ? (string)$payload['error'] : null
));
}
private function handleShellSessionEvents(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$afterId = max(0, (int)$this->fromQuery('after_id'));
$waitSeconds = (int)$this->fromQuery('wait_seconds');
if ($waitSeconds <= 0) {
$waitSeconds = edge_gateway_manager::SHELL_EVENT_POLL_TIMEOUT_SECONDS;
}
$response->success($manager->pollShellSessionEvents($gatewayId, $sessionId, $afterId, $waitSeconds));
}
private function handleShellSessionInput(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['data']);
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->enqueueShellInput(
$gatewayId,
$sessionId,
(string)self::getParameter('data'),
$user ? (int)$user->id : null
));
}
private function handleShellSessionResize(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['cols', 'rows']);
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->enqueueShellResize(
$gatewayId,
$sessionId,
(int)self::getParameter('cols'),
(int)self::getParameter('rows'),
$user ? (int)$user->id : null
));
}
private function handleShellSessionClose(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->requestShellSessionClose(
$gatewayId,
$sessionId,
$user ? (int)$user->id : null
));
}
private function handleAgentShellActionPoll(): void
private function handleAgentPresence(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::SHELL_ACTION_POLL_TIMEOUT_SECONDS;
$response->success((new edge_gateway_manager())->pollShellAction($gatewayId, $token, $waitSeconds));
}
$this->requireAgentToken($payload);
private function handleAgentShellActionResult(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$actionId = (int)$this->fromRoute('actionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($actionId, 'actionId');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$response->success((new edge_gateway_manager())->submitShellActionResult(
$response->success($this->manager()->recordBrokerPresence(
$gatewayId,
$actionId,
$token,
(bool)($payload['ok'] ?? false),
isset($payload['error']) ? (string)$payload['error'] : null
));
}
private function handleAgentShellEvents(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$events = isset($payload['events']) && is_array($payload['events'])
? (array)$payload['events']
: (isset($payload['event']) && is_array($payload['event']) ? [(array)$payload['event']] : []);
$response->success((new edge_gateway_manager())->recordShellSessionEvents(
$gatewayId,
$sessionId,
$token,
$events
));
}
private function handleBrokerGatewayValidate(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSharedSecret();
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
self::requireParameters(['token']);
$response->success((new edge_gateway_manager())->validateBrokerAgentConnection(
$gatewayId,
(string)$payload['token']
));
}
private function handleBrokerShellValidate(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSharedSecret();
$payload = self::getParametersAsArray();
self::requireParameters(['token']);
$response->success((new edge_gateway_manager())->validateShellSessionToken((string)$payload['token']));
}
private function handleBrokerGatewayPresence(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSharedSecret();
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
self::requireParameters(['status']);
$response->success((new edge_gateway_manager())->recordBrokerPresence(
$gatewayId,
(string)$payload['status'],
isset($payload['status']) ? (string)$payload['status'] : 'disconnected',
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null,
isset($payload['reason']) ? (string)$payload['reason'] : null,
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
));
}
private function handleBrokerShellSessionClose(): void
private function requireGatewayAccess(int $gatewayId): array
{
global /** @var response $response */ $response;
$this->requireBrokerSharedSecret();
$payload = self::getParametersAsArray();
self::requireParameters(['token']);
$response->success((new edge_gateway_manager())->closeShellSession(
(string)$payload['token'],
isset($payload['transcript']) ? (string)$payload['transcript'] : '',
isset($payload['reason']) ? (string)$payload['reason'] : 'broker_closed'
));
self::requireParameterIntPositive($gatewayId, 'id');
$gateway = $this->views()->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
return $gateway;
}
private function requireBrokerSharedSecret(): void
private function requireAgentToken(array $payload): string
{
global /** @var response $response */ $response;
$expected = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
if ($expected === '') {
$response->error('Edge broker secret is not configured', 503);
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? $this->fromRequest('broker_secret')));
if ($provided === '' || !hash_equals($expected, $provided)) {
$response->error('Forbidden', 403);
}
return $token;
}
private function actorUserId(): ?int
{
$user = (new authentication())->get_user();
return $user ? (int)$user->id : null;
}
private function views(): edge_gateway_view_service
{
return new edge_gateway_view_service();
}
private function registry(): edge_gateway_registry_service
{
return new edge_gateway_registry_service();
}
private function manager(): edge_gateway_manager
{
return new edge_gateway_manager();
}
private function operations(): edge_gateway_operation_service
{
return new edge_gateway_operation_service($this->manager());
}
private function install(): edge_gateway_install_service
{
return new edge_gateway_install_service();
}
}
@@ -101,6 +101,30 @@ class moduleStripeRoute
// Check if the order exists
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
$order->requireSelected();
if ($order->stripe_module_orders->exists()) {
$existingInvoice = [];
$shouldBlockInvoiceCreation = true;
try {
$existingInvoice = $order->stripe_module_orders->asArray();
$retrievedInvoice = $order->stripe_module_orders->retrievePaymentLink();
$existingStatus = (string)($retrievedInvoice->status ?? '');
$isTerminalInvoiceState = (bool)($retrievedInvoice->paid ?? false) === true
|| in_array($existingStatus, ['paid', 'void', 'uncollectible', 'deleted'], true);
$shouldBlockInvoiceCreation = !$isTerminalInvoiceState;
} catch (\Throwable) {
$shouldBlockInvoiceCreation = false;
}
if ($shouldBlockInvoiceCreation) {
$response->error([
'message' => 'A Stripe payment link is already active for this order.',
'code' => 'stripe_invoice_exists',
'stripeModuleOrders' => $existingInvoice,
], 409);
}
$order->clearStripeInvoicing();
}
// Log the action
(new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User sent an invoice');
// Create a customer account, if it doesn't exist
@@ -141,6 +165,56 @@ class moduleStripeRoute
]
);
/** Modules > Stripe > Cancel Invoice */
$this->delete('/modules/stripe/invoice', function () {
global $response;
self::requirePermission('modules_stripe_invoice_send');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('modules_stripe', 'global', 0, 0, 'MODULES_STRIPE', 'User tried to cancel a Stripe invoice without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['order_id']);
self::requireType((int)self::fromRequest('order_id'), self::TYPE_INT());
self::requireMinLength('order_id', 1);
self::requireMaxLength('order_id', 255);
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
$order->requireSelected();
if (!$order->stripe_module_orders->exists()) {
$response->success([
'stripeModuleOrders' => [],
]);
}
$invoice = $order->stripe_module_orders->retrievePaymentLink();
if ((bool)($invoice->paid ?? false) === true) {
$response->error([
'message' => 'A paid Stripe payment link cannot be cancelled.',
'code' => 'stripe_invoice_paid',
'stripeModuleOrders' => $order->stripe_module_orders->asArray(),
], 409);
}
$invoiceStatus = (string)($invoice->status ?? '');
if ($invoiceStatus !== 'void' && $invoiceStatus !== 'uncollectible' && $invoiceStatus !== 'deleted') {
(new stripe())->invoice->void((string)$order->stripe_module_orders->invoice_id->value());
}
$order->clearStripeInvoicing();
(new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User cancelled a Stripe invoice');
$response->success([
'stripeModuleOrders' => [],
]);
},
[
'modules_stripe_invoice_send' => 'Send invoice'
]
);
self::get('/modules/stripe/terminal/readers', function () {
global $response;
self::requirePermission('modules_stripe_terminal_readers_list');
@@ -7,6 +7,7 @@ use classes\response;
use classes\router;
use classes\weatherapi;
use classes\workfeed;
use classes\workfeed_shift_time_resolver;
use DateInterval;
use DateTime;
use DateTimeZone;
@@ -1634,7 +1635,12 @@ class moduleWeatherAPIRoute
return $update_time;
}
private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string|array $workfeed_department_ids, DateTime $slot_start): float
private function calculateWorkfeedEmployeeHoursForHour(
array $shifts,
string|array $workfeed_department_ids,
DateTime $slot_start,
?DateTime $occurred_until = null
): float
{
$department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids];
$department_id_lookup = [];
@@ -1652,6 +1658,7 @@ class moduleWeatherAPIRoute
$slot_end->add(new DateInterval('PT1H'));
$slot_start_ts = $slot_start->getTimestamp();
$slot_end_ts = $slot_end->getTimestamp();
$occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp();
$hours = 0.0;
foreach ($shifts as $shift) {
@@ -1660,36 +1667,13 @@ class moduleWeatherAPIRoute
continue;
}
$record = self::normalizeWorkfeedRecord($shift);
$shift_start = self::firstShiftDateTimeFromPaths($record, [
'actualStart',
'actualStartTime',
'clockIn',
'clockInTime',
'start',
'startTime',
'from',
'approval.originalStart',
]);
$shift_end = self::lastShiftDateTimeFromPaths($record, [
'actualEnd',
'actualEndTime',
'clockOut',
'clockOutTime',
'end',
'endTime',
'to',
'approval.originalEnd',
'approval.end',
]);
if ($shift_start === null || $shift_end === null) {
$timing = workfeed_shift_time_resolver::resolveShiftTiming($shift);
if ($timing === null) {
continue;
}
$shift_end = self::resolveEffectiveShiftEnd($record, $shift_start, $shift_end);
$shift_start_ts = $shift_start->getTimestamp();
$shift_end_ts = $shift_end->getTimestamp();
$shift_start_ts = $timing['actualStart']->getTimestamp();
$shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts);
if ($shift_end_ts <= $shift_start_ts) {
continue;
}
+184 -10
View File
@@ -3,6 +3,8 @@
namespace routes;
use classes\authentication;
use classes\order_bookings_counts_cache;
use classes\order_bookings_list_cache;
use classes\redis;
use Exception;
use modules\subusers\helpers\subusers_permission_node_key;
@@ -132,17 +134,41 @@ class orderBookingRoute
*/
$object = new order_bookings_o(); // New object for listing
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
$response->success($object->listObjectsWithPaginationIfSet(
$forcedFilters = $object->forceRestrictFilters([
...($has_permission_other && $user !== false ? [
'department' => $user->getGroup()->getDepartments()
] : []),
...(!$has_permission_other && $effectiveCustomer !== null ? [
'customer_number' => [(int)$effectiveCustomer]
] : [])
]);
$cacheTtl = order_bookings_list_cache::getTtl();
$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null;
if ($cacheKey !== null) {
$cachedPayload = order_bookings_list_cache::getPayload($cacheKey);
if ($cachedPayload !== null) {
$response->rawJson($cachedPayload);
}
}
$result = $object->listObjectsWithPaginationIfSet(
function ($booking) { return (new order_bookings_o())->select((int)$booking['id'])->asArray(); },
$object->forceRestrictFilters([
...($has_permission_other && $user !== false ? [
'department' => $user->getGroup()->getDepartments()
] : []),
...(!$has_permission_other && $effectiveCustomer !== null ? [
'customer_number' => [(int)$effectiveCustomer]
] : [])
])
));
$forcedFilters
);
$payload = [
'success' => true,
'data' => $result,
'meta' => $response->get_meta(),
'includes' => $response->get_includes(),
];
if ($cacheKey !== null) {
order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl);
}
$response->rawJson($payload);
},
[
'list_own_bookings' => 'Permission to list own order bookings.',
@@ -150,6 +176,77 @@ class orderBookingRoute
]
);
$this->get('/order-bookings/counts', function () {
global $response;
$auth = new authentication();
$user = $auth->get_user();
$permission_own = self::definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST);
$permission_other = self::definePermission('list_bookings');
$hasPermissionOwn = self::hasPermission($permission_own);
$hasPermissionOther = self::hasPermission($permission_other);
if (!$hasPermissionOwn && !$hasPermissionOther) {
$this->emitForbidden([$permission_own, $permission_other]);
}
$requestedDepartmentId = $this->getTargetCountDepartmentId();
if ($hasPermissionOther && $requestedDepartmentId !== null) {
self::requireDepartmentAccess((string)$requestedDepartmentId);
}
$effectiveCustomer = null;
if (!$hasPermissionOther) {
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
if ($effectiveCustomer === null || $effectiveCustomer < 1) {
$this->emitForbidden([$permission_other]);
}
}
$departmentIds = null;
if ($requestedDepartmentId !== null) {
$departmentIds = [$requestedDepartmentId];
} elseif ($hasPermissionOther && $user !== false) {
$departmentIds = array_map('intval', $user->getGroup()->getDepartments());
if ($departmentIds === []) {
$response->success([
'past' => 0,
'current' => 0,
'future' => 0,
]);
}
}
$cacheTtl = order_bookings_counts_cache::getTtl();
$cacheKey = $cacheTtl > 0
? $this->getOrderBookingsCountsCacheKey($departmentIds, $effectiveCustomer)
: null;
if ($cacheKey !== null) {
$cachedCounts = order_bookings_counts_cache::getCounts($cacheKey);
if ($cachedCounts !== null) {
$response->success($cachedCounts);
}
}
$counts = (new order_bookings_o())->getPendingBookingCounts(
$departmentIds,
$effectiveCustomer
);
if ($cacheKey !== null) {
order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl);
}
$response->success($counts);
},
[
'list_own_bookings' => 'Permission to list own order booking counts.',
'list_bookings' => 'Permission to list department order booking counts.',
]
);
$this->put('/order-bookings', function () {
// Require the user to be logged in
global $response;
@@ -300,6 +397,83 @@ class orderBookingRoute
}
private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string
{
global $response;
$page = (int)($response->getRequestParameter('page') ?? 0);
if ($page < 1) {
$page = 1;
}
$limit = (int)($response->getRequestParameter('limit') ?? 0);
if ($limit < 1) {
$limit = 1000;
}
return order_bookings_list_cache::buildKey([
'route' => '/order-bookings',
'page' => $page,
'limit' => $limit,
'search' => (string)($response->getRequestParameter('search') ?? ''),
'order' => $this->normalizeOrderBookingsListCacheOrder((string)($response->getRequestParameter('order') ?? 'id:ASC')),
'filters' => $object->filter_string_to_array($forcedFilters),
]);
}
private function getOrderBookingsCountsCacheKey(?array $departmentIds, ?int $customerNumber): string
{
return order_bookings_counts_cache::buildKey([
'route' => '/order-bookings/counts',
'day' => (new \DateTimeImmutable('now'))->format('Y-m-d'),
'department_ids' => $departmentIds ?? [],
'customer_number' => $customerNumber,
]);
}
private function normalizeOrderBookingsListCacheOrder(string $order): array
{
$normalized = trim($order);
if ($normalized === '') {
$normalized = 'id:ASC';
}
$parts = array_pad(explode(':', $normalized, 2), 2, 'ASC');
$field = trim((string)$parts[0]);
$direction = strtoupper(trim((string)$parts[1]));
if ($field === '') {
$field = 'id';
}
if ($direction === '') {
$direction = 'ASC';
}
return [$field => $direction];
}
private function getTargetCountDepartmentId(): ?int
{
global $response;
$parameter = 'department';
$rawValue = $this->fromRequest($parameter);
if ($rawValue === null || $rawValue === '') {
return null;
}
if (!is_numeric($rawValue)) {
$response->error('Invalid department', 400);
}
$value = (int)$rawValue;
if ($value < 1 || $value > 999999999) {
$response->error('Invalid department', 400);
}
return $value;
}
/**
* @throws Exception
*/
@@ -557,6 +557,11 @@ class orderInvoicesRoute
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
$collected_order_invoices->requireSelected();
try {
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
$response->error('Invoice has already been booked', 400);
@@ -992,6 +997,11 @@ class orderInvoicesRoute
// Validate the ID against the database
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
$collected_order_invoices->requireSelected();
try {
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
// Require the external ID to be set
if ($collected_order_invoices->external_id->value() === null) {
$response->error('Transaction has not been created in Stripe', 400);
@@ -1782,6 +1792,8 @@ class orderInvoicesRoute
*/
private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array
{
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
@@ -2023,6 +2035,15 @@ class orderInvoicesRoute
}, $jobs));
}
/**
* @throws Exception
*/
private function assertCollectedInvoiceCanBeExportedToEconomic(collected_order_invoices_o $collected_order_invoices): void
{
$collected_order_invoices->requireSelected();
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
}
/**
* @param $collected_order_invoice
* @param users_o $users
+58 -10
View File
@@ -6,6 +6,7 @@ use attachments\helpers\attachment_content;
use classes\attachment_store;
use classes\attachments;
use classes\authentication;
use classes\economic;
use classes\orders_input_normalizer;
use classes\response;
use classes\stripe;
@@ -986,19 +987,26 @@ class ordersRoute
break;
}
};
$shouldRefreshAttachedWashCertificate = false;
// PO
if (isset($data['po'])) {
$order->po->set((string)$data['po']);
}
// Registration numbers
if (isset($data['reg_1'])) {
$order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1']));
$normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_1->set($normalizedReg1);
}
if (isset($data['reg_2'])) {
$order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2']));
$normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_2->set($normalizedReg2);
}
if (isset($data['reg_3'])) {
$order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3']));
$normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_3->set($normalizedReg3);
}
// Reference
if (isset($data['reference'])) {
@@ -1009,7 +1017,12 @@ class ordersRoute
$order->notes->set((string)$data['notes']);
}
if (array_key_exists('safety_seal', $data)) {
$order->setSafetySealValue($data['safety_seal']);
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']);
$shouldRefreshAttachedWashCertificate = true;
$order->setSafetySealValue($normalizedSafetySeal);
}
if ($shouldRefreshAttachedWashCertificate) {
$order->regenerateAttachedWashCertificate();
}
// Register the change
$order->objectChanged();
@@ -1019,12 +1032,22 @@ class ordersRoute
// Admin/department path (requires edit_order)
self::requirePermission($permission_other);
/** Departmental access */
$originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
$shouldRefreshAttachedWashCertificate = false;
// If the customer ID is set, validate it
if (isset($data['customer_id'])) {
if (!(new users_o())->getUserByCustomerNumber((int)$data['customer_id'])->exists() || empty($data['customer_id'])) {
$response->error('Customer not found or invalid', 400);
}
$order->customer_id->set((int)$data['customer_id']);
$newCustomerNumber = (int)$data['customer_id'];
$shouldRefreshAttachedWashCertificate = true;
$shouldAutoReassignInvoiceCollection = $this->shouldAutoReassignInvoiceCollectionForDraftTransition(
$originalCustomerNumber,
$newCustomerNumber
);
$order->customer_id->set($newCustomerNumber);
}
// If the reference is set, validate it
if (isset($data['reference'])) {
@@ -1036,22 +1059,30 @@ class ordersRoute
}
// If the registration number is set, validate it
if (isset($data['reg_1'])) {
$order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1']));
$normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_1->set($normalizedReg1);
}
// If the registration number 2 is set, validate it
if (isset($data['reg_2'])) {
$order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2']));
$normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_2->set($normalizedReg2);
}
// If the registration number 3 is set, validate it
if (isset($data['reg_3'])) {
$order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3']));
$normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_3->set($normalizedReg3);
}
// If the PO is set, validate it
if (isset($data['po'])) {
$order->po->set((string)$data['po']);
}
if (array_key_exists('safety_seal', $data)) {
$order->setSafetySealValue($data['safety_seal']);
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']);
$shouldRefreshAttachedWashCertificate = true;
$order->setSafetySealValue($normalizedSafetySeal);
}
// If the lane is set, validate it
if (isset($data['lane'])) {
@@ -1069,7 +1100,7 @@ class ordersRoute
$order->booking_id->set((int)$data['booking_id']);
}
// Check if the invoice collection is set
if (isset($data['invoice_collection_id'])) {
if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) {
$order->invoice_collection_id->set((int)$data['invoice_collection_id']);
}
// Check if the wash_id is set
@@ -1094,6 +1125,12 @@ class ordersRoute
$response->error($e->getMessage(), 400);
}
}
if ($shouldAutoReassignInvoiceCollection) {
$order->assignToInvoiceCollection(null, false);
}
if ($shouldRefreshAttachedWashCertificate) {
$order->regenerateAttachedWashCertificate();
}
// Void any cached key for the order
$order->objectChanged();
// Log the incident
@@ -1149,6 +1186,17 @@ class ordersRoute
}
}
private function shouldAutoReassignInvoiceCollectionForDraftTransition(int $originalCustomerNumber, int $newCustomerNumber): bool
{
if ($originalCustomerNumber <= 0 || $newCustomerNumber <= 0 || $originalCustomerNumber === $newCustomerNumber) {
return false;
}
$economic = new economic();
return $economic->isDraftCustomerNumber($originalCustomerNumber)
|| $economic->isDraftCustomerNumber($newCustomerNumber);
}
/**
* @param array<int, array<string, mixed>> $orders
* @return array<int, array<string, mixed>>
+26 -1
View File
@@ -76,6 +76,7 @@ it('returns the cached auth session payload for a valid token', function (): voi
'two_factor_enabled' => false,
]
);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '445566', 'int');
$response = api_client()->get('/auth/session', $session['headers']);
@@ -89,7 +90,31 @@ it('returns the cached auth session payload for a valid token', function (): voi
->toHaveKey('customer_number', $session['user']['customer_number'])
->toHaveKey('two_factor_enabled', false)
->and($response->data()['permissions'])
->toContain('list_departments');
->toContain('list_departments')
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
->toBe(445566);
});
it('includes economic runtime config for uncached auth sessions', function (): void {
api_test_covers('GET /auth/session', 'happy');
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '556677', 'int');
$session = api_fixtures()->createUserSession(['list_departments'], [
'display_name' => 'Fresh Session User',
]);
$response = api_client()->get('/auth/session', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('customer_number', $session['user']['customer_number'])
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
->toBe(556677);
});
it('rejects invalid auth session tokens', function (): void {
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
usesApiSuite();
const ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
function economic_draft_customer_find_config_entry(array $entries, string $variable): ?array
{
foreach ($entries as $entry) {
if (is_array($entry) && ($entry['variable'] ?? null) === $variable) {
return $entry;
}
}
return null;
}
it('lists the draft customer config entry in economic config responses', function (): void {
api_test_covers('GET /economic/config', 'happy');
$session = api_fixtures()->createUserSession(['economic_config']);
$response = api_client()->get('/economic/config', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$entry = economic_draft_customer_find_config_entry((array)$response->data(), 'transactionDraftCustomerNumber');
expect($entry)
->not->toBeNull()
->and($entry['module'] ?? null)->toBe('economic')
->and($entry['type'] ?? null)->toBe('int')
->and(is_int($entry['value'] ?? null) || ($entry['value'] ?? null) === null)->toBeTrue();
});
it('round-trips the draft customer config value through economic config updates', function (): void {
api_test_covers('POST /economic/config', 'happy');
$session = api_fixtures()->createUserSession(['economic_config']);
api_client()->post('/economic/config', [
'variable' => 'transactionDraftCustomerNumber',
'value' => 667788,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$configuredResponse = api_client()->get('/economic/config?variable=transactionDraftCustomerNumber', $session['headers']);
$configuredResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$configuredEntry = economic_draft_customer_find_config_entry((array)$configuredResponse->data(), 'transactionDraftCustomerNumber');
expect($configuredEntry)
->not->toBeNull()
->and($configuredEntry['value'] ?? null)->toBe(667788);
api_client()->post('/economic/config', [
'variable' => 'transactionDraftCustomerNumber',
'value' => null,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$clearedResponse = api_client()->get('/economic/config?variable=transactionDraftCustomerNumber', $session['headers']);
$clearedResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$clearedEntry = economic_draft_customer_find_config_entry((array)$clearedResponse->data(), 'transactionDraftCustomerNumber');
expect($clearedEntry)->toBeArray();
expect(array_key_exists('value', $clearedEntry))->toBeTrue();
expect($clearedEntry['value'])->toBeNull();
});
it('rejects order draft exports for the configured draft customer', function (): void {
api_test_covers('POST /economic/invoice/draft/export', 'failure');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Export Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Draft Export Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $draftCustomer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'BLOCK-DRAFT',
'reg_1' => 'DRFT123',
]);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
$session = api_fixtures()->createUserSession(['economic_invoice_draft_export']);
api_client()->post('/economic/invoice/draft/export', [
'order_id' => $order['id'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
});
it('rejects booked invoice exports for the configured draft customer', function (): void {
api_test_covers('POST /economic/invoice/export', 'failure');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Booked Export Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Booked Export Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $draftCustomer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'BLOCK-INVOICE',
'reg_1' => 'INV1234',
]);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
$session = api_fixtures()->createUserSession(['economic_invoice_export']);
api_client()->post('/economic/invoice/export', [
'order_id' => $order['id'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
});
it('rejects collected invoice exports for the configured draft customer', function (): void {
api_test_covers('POST /collected-invoices/economic', 'failure');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Collected Export Customer']);
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $draftCustomer['customer_number'],
]);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
$session = api_fixtures()->createUserSession(['add_collected_invoice_economic']);
api_client()->post('/collected-invoices/economic', [
'id' => $invoiceCollection['id'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
});
@@ -4,6 +4,39 @@ declare(strict_types=1);
usesApiSuite();
function activeWashCertificateAttachmentIdsForOrder(int $orderId): array
{
$statement = api_test_runtime()->db()->prepare(
'SELECT id, content
FROM object_attachments
WHERE object_type IN (?, ?)
AND object_id = ?
AND deleted_at IS NULL
ORDER BY id ASC'
);
expect($statement)->not->toBeFalse();
$objectType = 'orders';
$backtickedObjectType = '`orders`';
$statement->bind_param('ssi', $objectType, $backtickedObjectType, $orderId);
$statement->execute();
$result = $statement->get_result();
$attachmentIds = [];
while ($row = $result->fetch_assoc()) {
$content = json_decode((string)($row['content'] ?? ''), true);
$other = is_array($content) ? ($content['other'] ?? null) : null;
if (is_string($other) && strtolower($other) === 'wash_certificate') {
$attachmentIds[] = (int)($row['id'] ?? 0);
}
}
$result->free();
$statement->close();
return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0));
}
it('lists orders for an admin-scoped user and limits the results to the permitted departments', function (): void {
api_test_covers('GET /orders', 'happy');
@@ -297,6 +330,190 @@ it('updates orders through the primary endpoint', function (): void {
expect((int)($row['include_in_invoice'] ?? 1))->toBe(0);
});
it('reassigns invoice collections when changing an order across the draft customer boundary', function (): void {
api_test_covers('PUT /orders', 'happy');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Customer']);
$regularCustomer = api_fixtures()->createUser(['display_name' => 'Regular Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Draft Boundary Department']);
$cashier = api_fixtures()->createUser(['display_name' => 'Draft Boundary Cashier']);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
$order = api_fixtures()->createOrder([
'customer_id' => $regularCustomer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'DRAFT-BOUNDARY',
'reg_1' => 'DRAFT123',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
api_client()->put('/orders', [
'id' => $order['id'],
'customer_id' => $draftCustomer['customer_number'],
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
$draftRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($draftRow)->not->toBeNull();
$draftInvoiceCollectionId = (int)($draftRow['invoice_collection_id'] ?? 0);
expect($draftInvoiceCollectionId)->toBeGreaterThan(0);
$draftCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $draftInvoiceCollectionId);
expect($draftCollectionRow)->not->toBeNull();
expect((int)($draftCollectionRow['customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']);
api_client()->put('/orders', [
'id' => $order['id'],
'customer_id' => $regularCustomer['customer_number'],
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
$regularRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($regularRow)->not->toBeNull();
$regularInvoiceCollectionId = (int)($regularRow['invoice_collection_id'] ?? 0);
expect($regularInvoiceCollectionId)->toBeGreaterThan(0);
$regularCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $regularInvoiceCollectionId);
expect($regularCollectionRow)->not->toBeNull();
expect((int)($regularCollectionRow['customer_number'] ?? 0))->toBe((int)$regularCustomer['customer_number']);
});
it('regenerates attached wash certificates when certificate metadata changes through the primary endpoint', function (): void {
api_test_covers('PUT /orders', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Original Certificate Customer']);
$updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Certificate Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Certificate Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'CERT-REF',
'reg_1' => 'CERT123',
'reg_2' => 'TRAIL1',
'safety_seal' => 'SEAL-OLD',
'created_at' => '2026-04-12 10:15:00',
]);
$session = api_fixtures()->createUserSession([
'edit_order',
'add_order_attachments',
'department_access_' . $department['id'],
]);
api_client()->post('/orders/attachments/upload', [
'order_id' => $order['id'],
'base64_file' => base64_encode('wash-certificate'),
'file_name' => 'wash_certificate',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
expect($initialAttachmentIds)->toHaveCount(1);
$initialAttachmentId = $initialAttachmentIds[0];
$initialAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
expect($initialAttachmentRow)->not->toBeNull();
$initialDocument = (json_decode((string)($initialAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null;
api_client()->put('/orders', [
'id' => $order['id'],
'customer_id' => $updatedCustomer['customer_number'],
'reg_2' => ' new-trail-55 ',
'safety_seal' => 'SEAL-NEW',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
$orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($orderRow)->not->toBeNull();
expect((int)($orderRow['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']);
expect($orderRow['reg_2'] ?? null)->toBe('NEWTRAIL55');
expect($orderRow['safety_seal'] ?? null)->toBe('SEAL-NEW');
$updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
expect($updatedAttachmentIds)->toHaveCount(1);
expect($updatedAttachmentIds)->not->toContain($initialAttachmentId);
$updatedAttachmentId = $updatedAttachmentIds[0];
$updatedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $updatedAttachmentId);
$deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
expect($updatedAttachmentRow)->not->toBeNull();
expect($deletedAttachmentRow)->not->toBeNull();
expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull();
expect((json_decode((string)($updatedAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null)->not->toBe($initialDocument);
});
it('regenerates attached wash certificates for legacy field-value updates through the alias endpoint', function (): void {
api_test_covers('PUT /order', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Alias Wash Certificate Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Alias Certificate Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Alias Certificate Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'ALIAS-CERT',
'reg_1' => 'ALIAS123',
'safety_seal' => 'ALIAS-SEAL',
]);
$session = api_fixtures()->createUserSession([
'edit_order',
'add_order_attachments',
'department_access_' . $department['id'],
]);
api_client()->post('/orders/attachments/upload', [
'order_id' => $order['id'],
'base64_file' => base64_encode('wash-certificate'),
'file_name' => 'wash_certificate',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
expect($initialAttachmentIds)->toHaveCount(1);
$initialAttachmentId = $initialAttachmentIds[0];
api_client()->put('/order', [
'id' => $order['id'],
'field' => 'reg_1',
'value' => ' zz-88 11 ',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
$orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($orderRow)->not->toBeNull();
expect($orderRow['reg_1'] ?? null)->toBe('ZZ8811');
$updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
expect($updatedAttachmentIds)->toHaveCount(1);
expect($updatedAttachmentIds)->not->toContain($initialAttachmentId);
$deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
expect($deletedAttachmentRow)->not->toBeNull();
expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull();
});
it('supports legacy field-value metadata updates through the primary endpoint', function (): void {
api_test_covers('PUT /orders', 'happy');
@@ -2,8 +2,26 @@
declare(strict_types=1);
use classes\email;
use classes\stripe;
use classes\stripe_fake_http_client;
putenv('STRIPE_FAKE_MODE=1');
putenv('STRIPE_FAKE_STORE_PATH=' . sys_get_temp_dir() . '/truckwash-stripe-api-tests.json');
putenv('EMAIL_FAKE_MODE=1');
usesApiSuite();
beforeEach(function (): void {
stripe_fake_http_client::resetStore();
email::resetFakeDeliveries();
if (api_tests_enabled()) {
api_test_runtime()->db()->query("DELETE FROM stripe_module_orders WHERE invoice_id LIKE 'in_fake_%'");
api_test_runtime()->db()->query("DELETE FROM stripe_module_customers WHERE customer_id LIKE 'cus_fake_%' OR email LIKE 'stripe-%@example.com'");
}
});
it('returns a setup required error when department terminal readers are requested without terminal setup', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Setup Pending Department',
@@ -29,6 +47,248 @@ it('returns a setup required error when department terminal readers are requeste
->toHaveKey('code', 'stripe_terminal_setup_required');
});
it('sends a Stripe invoice by email and persists the hosted invoice association', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Payments Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Hosted Invoice Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-SEND',
'reg_1' => 'EMAIL01',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-email-%d@example.com', (int)$order['id']);
$response = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess(true);
expect($response->data())
->toBeArray()
->toHaveKey('id')
->toHaveKey('hosted_invoice_url')
->toHaveKey('metadata');
expect($response->data()['metadata'] ?? [])
->toMatchArray([
'order_id' => (string)$order['id'],
'customer_id' => (string)$customer['customer_number'],
'department_id' => (string)$department['id'],
'reference' => 'STRIPE-EMAIL-SEND',
'reg_1' => 'EMAIL01',
])
->and(($response->data()['metadata']['stripe_customer_id'] ?? null))
->toBe((string)($response->data()['customer'] ?? ''));
$retrievedInvoice = (new stripe())->invoice->retrieve((string)$response->data()['id']);
$retrievedMetadata = json_decode(json_encode($retrievedInvoice->metadata), true);
if (!is_array($retrievedMetadata)) {
$retrievedMetadata = [];
}
expect($retrievedMetadata)
->toMatchArray([
'order_id' => (string)$order['id'],
'customer_id' => (string)$customer['customer_number'],
'department_id' => (string)$department['id'],
'reference' => 'STRIPE-EMAIL-SEND',
'reg_1' => 'EMAIL01',
])
->and(($retrievedMetadata['stripe_customer_id'] ?? null))
->toBe((string)($response->data()['customer'] ?? ''));
$stored = api_test_runtime()->queryOne(
'SELECT invoice_id, customer_id, url FROM stripe_module_orders WHERE id = ' . (int)$order['id']
);
expect($stored)
->not->toBeNull()
->and($stored['invoice_id'] ?? null)->toBe((string)$response->data()['id'])
->and($stored['customer_id'] ?? null)->not->toBe('')
->and($stored['url'] ?? null)->toBe((string)$response->data()['hosted_invoice_url']);
});
it('returns a conflict when a Stripe hosted invoice is already active for the order', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Guard Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Duplicate Guard Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-GUARD',
'reg_1' => 'EMAIL02',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-duplicate-%d@example.com', (int)$order['id']);
api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers'])->assertStatus(200);
$response = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('A Stripe payment link is already active for this order.');
expect($response->data())
->toBeArray()
->toHaveKey('code', 'stripe_invoice_exists');
});
it('allows sending a new Stripe hosted invoice when the existing association is already terminal', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Terminal Guard Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Terminal Guard Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-TERMINAL',
'reg_1' => 'EMAIL05',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-terminal-%d@example.com', (int)$order['id']);
$firstResponse = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$firstInvoiceId = (string)($firstResponse->data()['id'] ?? '');
stripe_fake_http_client::setInvoiceState($firstInvoiceId, [
'status' => 'void',
'paid' => false,
]);
$secondResponse = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$secondResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess(true);
expect((string)($secondResponse->data()['id'] ?? ''))
->not->toBe('')
->not->toBe($firstInvoiceId);
});
it('voids an unpaid Stripe hosted invoice and clears the local order association', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Cancel Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Cancel Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-CANCEL',
'reg_1' => 'EMAIL03',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-cancel-%d@example.com', (int)$order['id']);
$sendResponse = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$invoiceId = (string)($sendResponse->data()['id'] ?? '');
$response = api_client()->delete('/modules/stripe/invoice', [
'order_id' => $order['id'],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess(true);
expect(api_test_runtime()->queryOne(
'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id']
))->toBeNull();
expect((new stripe())->invoice->retrieve($invoiceId)->status)->toBe('void');
});
it('refuses to cancel a paid Stripe hosted invoice', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Paid Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Paid Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-PAID',
'reg_1' => 'EMAIL04',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-paid-%d@example.com', (int)$order['id']);
$sendResponse = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$invoiceId = (string)($sendResponse->data()['id'] ?? '');
stripe_fake_http_client::setInvoiceState($invoiceId, [
'status' => 'paid',
'paid' => true,
'amount_due' => 0,
'amount_paid' => 1000,
]);
$response = api_client()->delete('/modules/stripe/invoice', [
'order_id' => $order['id'],
], $session['headers']);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('A paid Stripe payment link cannot be cancelled.');
expect(api_test_runtime()->queryOne(
'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id']
))->not->toBeNull();
});
it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Payment Intent Pending Department',
@@ -18,6 +18,60 @@ function app_require(string $relative): void
require_once app_path($relative);
}
spl_autoload_register(function (string $class): void {
$class = ltrim($class, '\\');
if ($class === '') {
return;
}
$parts = explode('\\', $class);
$top = strtolower($parts[0] ?? '');
$relative = implode(DIRECTORY_SEPARATOR, array_slice($parts, 1));
if ($relative === '') {
return;
}
$base = app_path();
$candidates = [];
if (in_array($top, ['classes', 'interfaces', 'traits', 'objects', 'routes', 'statistics'], true)) {
$candidates[] = $base . DIRECTORY_SEPARATOR . $top . DIRECTORY_SEPARATOR . $relative;
} elseif ($top === 'modules') {
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $relative;
} else {
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $class);
$modules_root = $base . DIRECTORY_SEPARATOR . 'modules';
if (is_dir($modules_root)) {
$module_dirs = array_filter(scandir($modules_root) ?: [], static function (string $entry) use ($modules_root): bool {
return $entry !== '.' && $entry !== '..' && is_dir($modules_root . DIRECTORY_SEPARATOR . $entry);
});
foreach ($module_dirs as $module_dir) {
$candidates[] = $modules_root . DIRECTORY_SEPARATOR . $module_dir . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $class);
}
}
}
foreach ($candidates as $path) {
foreach (['', '_t', '_o', '_s', '_i', '_c', '_m'] as $suffix) {
$file = $path . $suffix . '.php';
if (!is_file($file)) {
continue;
}
require_once $file;
if (
class_exists($class, false)
|| interface_exists($class, false)
|| trait_exists($class, false)
|| (function_exists('enum_exists') && enum_exists($class, false))
) {
return;
}
}
}
});
function integration_enabled(): bool
{
return getenv('RUN_INTEGRATION_TESTS') === '1';
@@ -0,0 +1,119 @@
<?php
app_require('classes/order_bookings_counts_cache.php');
use classes\order_bookings_counts_cache;
if (!class_exists('OrderBookingsCountsCacheRedisFake')) {
class OrderBookingsCountsCacheRedisFake
{
/** @var array<string,string> */
public array $store = [];
public function get(string $key): ?string
{
return $this->store[$key] ?? null;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key) === 1) {
unset($this->store[$key]);
}
}
}
}
}
beforeEach(function (): void {
$this->oldTtl = getenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
$this->redis = new OrderBookingsCountsCacheRedisFake();
order_bookings_counts_cache::setAdapterForTests($this->redis);
});
afterEach(function (): void {
order_bookings_counts_cache::setAdapterForTests(null);
if ($this->oldTtl === false) {
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
return;
}
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=' . $this->oldTtl);
});
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
expect(order_bookings_counts_cache::getTtl())->toBe(30);
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=20');
expect(order_bookings_counts_cache::getTtl())->toBe(20);
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=-1');
expect(order_bookings_counts_cache::getTtl())->toBe(0);
});
it('builds deterministic keys for equivalent count contexts', function (): void {
$keyA = order_bookings_counts_cache::buildKey([
'route' => '/order-bookings/counts',
'day' => '2026-04-21',
'department_ids' => ['3', '1'],
'customer_number' => null,
]);
$keyB = order_bookings_counts_cache::buildKey([
'customer_number' => null,
'route' => '/order-bookings/counts',
'department_ids' => [1, 3],
'day' => '2026-04-21',
]);
expect($keyA)->toBe($keyB);
expect($keyA)->toStartWith(order_bookings_counts_cache::PREFIX);
});
it('stores and retrieves normalized booking counts', function (): void {
$key = order_bookings_counts_cache::buildKey([
'route' => '/order-bookings/counts',
'day' => '2026-04-21',
'department_ids' => [12],
]);
order_bookings_counts_cache::storeCounts($key, [
'past' => '2',
'current' => 3,
'future' => 1,
], 60);
expect(order_bookings_counts_cache::getCounts($key))->toEqual([
'past' => 2,
'current' => 3,
'future' => 1,
]);
});
it('ignores malformed payloads and clears order-bookings count caches', function (): void {
$validKey = order_bookings_counts_cache::buildKey([
'route' => '/order-bookings/counts',
'day' => '2026-04-21',
]);
$this->redis->store[$validKey] = '{"past":1,"current":2,"future":3}';
$this->redis->store[order_bookings_counts_cache::PREFIX . 'broken'] = '{"count":5}';
$this->redis->store['other:key'] = '{"keep":true}';
expect(order_bookings_counts_cache::getCounts(order_bookings_counts_cache::PREFIX . 'broken'))->toBeNull();
order_bookings_counts_cache::clearAll();
expect($this->redis->store)->toHaveKey('other:key');
expect($this->redis->store)->not->toHaveKey($validKey);
expect($this->redis->store)->not->toHaveKey(order_bookings_counts_cache::PREFIX . 'broken');
});
@@ -0,0 +1,23 @@
<?php
it('adds a cached order-bookings counts endpoint and invalidates it on booking changes', function (): void {
$routeFile = app_path('routes/orderBookingRoute.php');
$objectFile = app_path('objects/order_bookings_o.php');
expect(is_file($routeFile))->toBeTrue();
expect(is_file($objectFile))->toBeTrue();
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
$objectCode = preg_replace('/\s+/', ' ', (string)file_get_contents($objectFile));
expect($routeCode)->toContain('use classes\order_bookings_counts_cache;');
expect($routeCode)->toContain("\$this->get('/order-bookings/counts', function () {");
expect($routeCode)->toContain('$cacheTtl = order_bookings_counts_cache::getTtl();');
expect($routeCode)->toContain('$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsCountsCacheKey(');
expect($routeCode)->toContain('$cachedCounts = order_bookings_counts_cache::getCounts($cacheKey);');
expect($routeCode)->toContain('order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl);');
expect($routeCode)->toContain('getPendingBookingCounts(');
expect($objectCode)->toContain('use classes\order_bookings_counts_cache;');
expect($objectCode)->toContain('order_bookings_counts_cache::clearAll();');
});
@@ -0,0 +1,143 @@
<?php
app_require('classes/order_bookings_list_cache.php');
use classes\order_bookings_list_cache;
if (!class_exists('OrderBookingsListCacheRedisFake')) {
class OrderBookingsListCacheRedisFake
{
/** @var array<string,string> */
public array $store = [];
public function reset(): void
{
$this->store = [];
}
public function get(string $key): ?string
{
return $this->store[$key] ?? null;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key) === 1) {
unset($this->store[$key]);
}
}
}
}
}
beforeEach(function (): void {
$this->oldTtl = getenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
$this->redis = new OrderBookingsListCacheRedisFake();
order_bookings_list_cache::setAdapterForTests($this->redis);
});
afterEach(function (): void {
order_bookings_list_cache::setAdapterForTests(null);
if ($this->oldTtl === false) {
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
return;
}
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=' . $this->oldTtl);
});
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
expect(order_bookings_list_cache::getTtl())->toBe(30);
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=45');
expect(order_bookings_list_cache::getTtl())->toBe(45);
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=-10');
expect(order_bookings_list_cache::getTtl())->toBe(0);
});
it('builds deterministic keys for equivalent contexts', function (): void {
$keyA = order_bookings_list_cache::buildKey([
'route' => '/order-bookings',
'page' => 1,
'limit' => 100,
'search' => '',
'order' => ['datetime' => 'DESC'],
'filters' => [
'customer_number' => '42',
'department' => ['3', '1'],
'order_id' => null,
],
]);
$keyB = order_bookings_list_cache::buildKey([
'search' => '',
'route' => '/order-bookings',
'limit' => 100,
'filters' => [
'order_id' => null,
'department' => [1, 3],
'customer_number' => 42,
],
'order' => ['datetime' => 'DESC'],
'page' => 1,
]);
expect($keyA)->toBe($keyB);
expect($keyA)->toStartWith(order_bookings_list_cache::PREFIX);
});
it('stores and retrieves full order-bookings payloads', function (): void {
$key = order_bookings_list_cache::buildKey([
'route' => '/order-bookings',
'page' => 1,
'limit' => 100,
]);
$payload = [
'success' => true,
'data' => [
['id' => 123],
],
'meta' => [
'pagination' => [
'page' => 1,
'per_page' => 100,
'total' => 1,
],
],
'includes' => [],
];
order_bookings_list_cache::storePayload($key, $payload, 60);
expect(order_bookings_list_cache::getPayload($key))->toBe($payload);
});
it('ignores malformed cached payloads and clears order-bookings list caches', function (): void {
$validKey = order_bookings_list_cache::buildKey([
'route' => '/order-bookings',
'page' => 1,
]);
$this->redis->store[$validKey] = '{"success":true,"data":[]}';
$this->redis->store[order_bookings_list_cache::PREFIX . 'broken'] = '{"not":"a payload"}';
$this->redis->store['other:cache:key'] = '{"leave":"me"}';
expect(order_bookings_list_cache::getPayload(order_bookings_list_cache::PREFIX . 'broken'))->toBeNull();
order_bookings_list_cache::clearAll();
expect($this->redis->store)->toHaveKey('other:cache:key');
expect($this->redis->store)->not->toHaveKey($validKey);
expect($this->redis->store)->not->toHaveKey(order_bookings_list_cache::PREFIX . 'broken');
});
@@ -0,0 +1,24 @@
<?php
it('caches the paginated order-bookings list response and invalidates it on booking changes', function (): void {
$routeFile = app_path('routes/orderBookingRoute.php');
$objectFile = app_path('objects/order_bookings_o.php');
expect(is_file($routeFile))->toBeTrue();
expect(is_file($objectFile))->toBeTrue();
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
$objectCode = preg_replace('/\s+/', ' ', (string)file_get_contents($objectFile));
expect($routeCode)->toContain('use classes\order_bookings_list_cache;');
expect($routeCode)->toContain('$cacheTtl = order_bookings_list_cache::getTtl();');
expect($routeCode)->toContain('$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null;');
expect($routeCode)->toContain('$cachedPayload = order_bookings_list_cache::getPayload($cacheKey);');
expect($routeCode)->toContain('$response->rawJson($cachedPayload);');
expect($routeCode)->toContain('order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl);');
expect($routeCode)->toContain('$response->rawJson($payload);');
expect($routeCode)->toContain('private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string');
expect($objectCode)->toContain('use classes\order_bookings_list_cache;');
expect($objectCode)->toContain('order_bookings_list_cache::clearAll();');
});
@@ -217,8 +217,8 @@ it('builds the overview payload from batched repository data with deterministic
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-23T09:00:00+00:00',
'end' => '2026-03-23T17:00:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-23T17:30:00+00:00'],
'end' => '2026-03-23T17:30:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-23T17:00:00+00:00'],
],
(object)[
'departmentID' => 'dep_2',
@@ -299,13 +299,44 @@ it('limits overtime counting to the selected reporting range', function (): void
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
'start' => '2026-03-22T20:00:00+00:00',
'end' => '2026-03-22T23:45:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-23T00:30:00+00:00'],
'end' => '2026-03-23T00:30:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-22T23:45:00+00:00'],
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
expect($hours)->toBe(0.5);
});
it('does not count negative approved overtime when a saved end shortens the shift', function (): void {
$route = new DepartmentDailyReportsOverviewRouteDouble();
$route->repository = new FakeDailyReportRepository();
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
'start' => '2026-03-23T10:00:00+00:00',
'end' => '2026-03-23T17:00:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-23T18:00:00+00:00'],
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
expect($hours)->toBe(0.0);
});
it('ignores late unapproved administrative edits when calculating overtime', function (): void {
$route = new DepartmentDailyReportsOverviewRouteDouble();
$route->repository = new FakeDailyReportRepository();
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
'start' => '2026-03-23T09:00:00+00:00',
'end' => '2026-03-23T17:00:00+00:00',
'approval' => null,
'updateTime' => '2026-03-24T02:30:00+00:00',
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
expect($hours)->toBe(0.0);
});
it('wires the overview route to batched repository methods and overview path', function (): void {
$routeContent = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php'));
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
function economic_draft_customer_openapi_specs_or_skip(): array
{
$root = dirname(__DIR__, 7);
$candidates = [
$root . DIRECTORY_SEPARATOR . 'backend-php' . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'openapi.yaml',
$root . DIRECTORY_SEPARATOR . 'backend-php' . DIRECTORY_SEPARATOR . 'openapi.yaml',
$root . DIRECTORY_SEPARATOR . 'front-end-vue' . DIRECTORY_SEPARATOR . 'openapi.yaml',
];
$specs = [];
foreach ($candidates as $candidate) {
if (!is_file($candidate)) {
test()->markTestSkipped('One or more tracked openapi.yaml copies are not available in this runtime environment.');
}
$content = file_get_contents($candidate);
if ($content === false) {
test()->markTestSkipped('Failed to read one or more tracked openapi.yaml copies.');
}
$specs[$candidate] = $content;
}
return $specs;
}
it('documents transaction draft customer config and auth runtime fields in all tracked openapi copies', function (): void {
foreach (economic_draft_customer_openapi_specs_or_skip() as $path => $content) {
expect($content, $path)->toContain('/auth/session:');
expect($content, $path)->toContain('transaction_draft_customer_number:');
expect($content, $path)->toContain('EconomicConfigEntry:');
expect($content, $path)->toContain('transactionDraftCustomerNumber');
expect($content, $path)->toContain('nullable: true');
}
});
@@ -0,0 +1,176 @@
<?php
app_require('classes/edge_gateway_agent_artifact_locator.php');
app_require('classes/edge_gateway_install_service.php');
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_agent_artifact_locator;
use classes\edge_gateway_install_service;
use classes\edge_gateway_manager;
class EdgeGatewayInstallServiceHarness extends edge_gateway_install_service
{
public function __construct()
{
}
}
class EdgeGatewayManagerArtifactHarness extends edge_gateway_manager
{
public function __construct()
{
}
}
function with_edge_gateway_artifact_env(array $values, callable $callback): void
{
$keys = ['EDGE_AGENT_ARTIFACT_DIR'];
$originals = [];
foreach ($keys as $key) {
$originals[$key] = getenv($key);
}
try {
foreach ($keys as $key) {
if (array_key_exists($key, $values) && $values[$key] !== null) {
putenv($key . '=' . $values[$key]);
continue;
}
putenv($key);
}
$callback();
} finally {
foreach ($originals as $key => $value) {
if ($value === false) {
putenv($key);
continue;
}
putenv($key . '=' . $value);
}
}
}
function remove_edge_gateway_temp_path(string $path): void
{
if (is_file($path)) {
unlink($path);
return;
}
if (is_dir($path)) {
rmdir($path);
}
}
it('resolves edge-agent artifacts from a supported runtime layout', function (): void {
$path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path());
expect(str_replace('\\', '/', $path))->toEndWith('/resources/edge-gateway-agent/agent.php');
expect(is_file($path))->toBeTrue();
});
it('prioritizes router resources before mounted and baked-in artifact directories', function (): void {
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void {
$candidatePaths = array_map(
static fn(string $path): string => str_replace('\\', '/', $path),
edge_gateway_agent_artifact_locator::candidatePaths(
'agent.php',
'/var/www/html',
'/services/edge-agent/php-agent',
'/opt/truckwash-edge-agent-artifacts'
)
);
expect(array_slice($candidatePaths, 0, 3))->toBe([
'/var/www/html/resources/edge-gateway-agent/agent.php',
'/services/edge-agent/php-agent/agent.php',
'/opt/truckwash-edge-agent-artifacts/agent.php',
]);
expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php');
expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php');
});
});
it('reads install artifacts through the shared locator', function (): void {
$service = new EdgeGatewayInstallServiceHarness();
$contents = $service->readArtifact('truckwash-edge-agent.service');
expect($contents)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php');
});
it('builds update payloads with checksums from resolved artifact paths', function (): void {
$manager = new EdgeGatewayManagerArtifactHarness();
$payload = $manager->buildUpdateOperationRequest('2.0.0', 'stable');
expect($payload['artifactSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/agent.php')));
expect($payload['serviceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-agent.service')));
});
it('falls back to baked-in artifacts when the mount path is absent', function (): void {
$tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-baked-' . bin2hex(random_bytes(6));
$appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app';
$missingMountPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent';
$bakedPath = $tempRoot . DIRECTORY_SEPARATOR . 'baked-artifacts';
$bakedAgentPath = $bakedPath . DIRECTORY_SEPARATOR . 'agent.php';
@mkdir($appPath, 0777, true);
@mkdir($bakedPath, 0777, true);
file_put_contents($bakedAgentPath, "<?php\n// baked artifact\n");
try {
$resolvedPath = edge_gateway_agent_artifact_locator::resolve(
'agent.php',
$appPath,
$missingMountPath,
$bakedPath
);
expect(str_replace('\\', '/', $resolvedPath))->toBe(str_replace('\\', '/', $bakedAgentPath));
} finally {
remove_edge_gateway_temp_path($bakedAgentPath);
foreach ([
$bakedPath,
$appPath,
dirname($appPath),
dirname(dirname($appPath)),
dirname($missingMountPath),
dirname(dirname($missingMountPath)),
$tempRoot,
] as $directory) {
remove_edge_gateway_temp_path($directory);
}
}
});
it('explains the legacy dist mount mismatch when php artifacts are unavailable', function (): void {
$tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-artifacts-' . bin2hex(random_bytes(6));
$appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app';
$legacyDistPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist';
@mkdir($appPath, 0777, true);
@mkdir($legacyDistPath, 0777, true);
file_put_contents($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs', '// legacy node artifact');
try {
edge_gateway_agent_artifact_locator::resolve('agent.php', $appPath);
test()->fail('Expected artifact resolution to fail when php-agent artifacts are missing.');
} catch (Exception $exception) {
expect($exception->getMessage())->toContain('Missing edge agent artifact: agent.php.');
expect($exception->getMessage())->toContain('Deploy the router-owned artifacts under');
expect($exception->getMessage())->toContain('Legacy Node dist artifact found at');
expect($exception->getMessage())->toContain('Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.');
} finally {
remove_edge_gateway_temp_path($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs');
foreach ([
$legacyDistPath,
dirname($legacyDistPath),
$appPath,
dirname($appPath),
dirname(dirname($appPath)),
$tempRoot,
] as $directory) {
remove_edge_gateway_temp_path($directory);
}
}
});
@@ -0,0 +1,91 @@
<?php
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('summarizes fleet usage statistics for the dashboard landing view', function (): void {
$summary = edge_gateway_manager::summarizeFleetUsage([
[
'id' => 701,
'department_id' => 1,
'status' => edge_gateway_manager::STATUS_ONLINE,
'version_drift' => ['is_drifted' => true],
'channel_status' => ['broker' => ['connected' => true]],
'active_operation' => ['id' => 91, 'type' => 'DISCOVERY'],
'recent_operations_summary' => ['pending' => 1, 'in_progress' => 1],
'backlog_depth' => ['operations' => 2, 'commands' => 3],
'metadata' => [
'system_metrics' => [
'latency_ms' => 184,
'cpu_usage_pct' => 27,
'memory_usage_pct' => 61,
'disk_usage_pct' => 58,
],
],
],
[
'id' => 702,
'department_id' => 2,
'status' => edge_gateway_manager::STATUS_OFFLINE,
'version_drift' => ['is_drifted' => false],
'channel_status' => ['broker' => ['connected' => false]],
'active_operation' => null,
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
'backlog_depth' => ['operations' => 0, 'commands' => 1],
'metadata' => [
'system_metrics' => [
'latency_ms' => 412,
'cpu_usage_pct' => 9,
'memory_usage_pct' => 42,
'disk_usage_pct' => 76,
],
],
],
], [
'total' => 2,
'online' => 1,
'offline' => 1,
], [
'total' => 3,
'fallback_overrides' => 2,
'cloud_only' => 1,
'local_only' => 1,
]);
expect($summary['gateways'])->toBe([
'total' => 2,
'departments' => 2,
'online' => 1,
'offline' => 1,
'degraded' => 0,
'drifted' => 1,
'broker_connected' => 1,
]);
expect($summary['inventory'])->toBe([
'total' => 2,
'online' => 1,
'offline' => 1,
]);
expect($summary['bindings'])->toBe([
'total' => 3,
'fallback_overrides' => 2,
'cloud_only' => 1,
'local_only' => 1,
]);
expect($summary['operations'])->toBe([
'active' => 1,
'pending' => 1,
'in_progress' => 1,
'backlog' => 2,
]);
expect($summary['commands'])->toBe([
'backlog' => 4,
]);
expect($summary['system'])->toBe([
'latency_ms_avg' => 298,
'cpu_usage_pct_avg' => 18,
'memory_usage_pct_avg' => 52,
'disk_usage_pct_avg' => 67,
]);
});
@@ -4,50 +4,45 @@ app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('queues admin commands, exposes agent poll/result handlers, and keeps heartbeats from mutating discovery state', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
it('keeps relay dispatch and discovery queueing on the edge gateway manager', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$operationServiceSource = file_get_contents(app_path('classes/edge_gateway_operation_service.php'));
expect($source)->not->toBeFalse();
expect($source)->toContain('public function pollCommand');
expect($source)->toContain('public function submitCommandResult');
expect($source)->toContain('private function waitForCommandResult');
expect($source)->toContain('private function claimNextCommandJob');
expect($source)->toContain("COALESCE(updated_at, created_at, requested_at) <= :stale_before");
expect($source)->toContain("throw new Exception('Edge gateway command timed out');");
expect($source)->toContain("throw new Exception('Gateway agent is offline');");
expect($source)->toContain("\$gateway->discovery_status->set('PENDING');");
expect($source)->toContain("\$gateway->discovery_status->set('READY');");
expect($source)->toContain("\$gateway->discovery_status->set('FAILED');");
expect($source)->toContain("\$this->buildUpdateCommandPayload(\$targetVersion, \$releaseChannel)");
expect($source)->toContain("'UNINSTALL_AGENT'");
expect($source)->toContain("public function queueUninstall");
expect($source)->toContain("public function deleteGateway");
expect($source)->toContain("private function softDeleteGatewayRelations");
expect($source)->toContain("\$updateJob->status->set('DISPATCHING');");
expect($source)->toContain("\$updateJob->status->set('VERIFYING');");
expect($source)->toContain("\$updateJob->status->set(\$finalStatus ?? (\$ok ? 'COMPLETED' : 'FAILED'));");
expect($source)->toContain("\$this->applyHeartbeatUpdateLifecycle(\$gateway, \$payload);");
expect($source)->not->toContain("\$gateway->discovery_status->set((string)(\$payload['discovery_status'] ?? \$gateway->discovery_status->value()));");
expect($managerSource)->not->toBeFalse();
expect($managerSource)->toContain('public function queueDiscovery');
expect($managerSource)->toContain('public function dispatchRelayStatus');
expect($managerSource)->toContain('public function dispatchRelaySwitch');
expect($managerSource)->toContain('private function createCommandJob');
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
expect($managerSource)->toContain('public function rotateGatewayCredentials');
expect($operationServiceSource)->toContain('public function queueOperation');
expect($operationServiceSource)->toContain('public function claimNextOperation');
expect($operationServiceSource)->toContain('public function completeAgentOperation');
expect($operationServiceSource)->toContain('public const OPERATION_LEASE_SECONDS = 45;');
expect($operationServiceSource)->toContain('private function refreshOperationLease');
expect($operationServiceSource)->toContain('agent_instance_id');
expect($operationServiceSource)->toContain('lease_expires_at');
expect($operationServiceSource)->toContain("'operation_type' => \$type");
expect($managerSource)->toContain("command_type");
});
it('defines the dispatchable gateway guard and api-polled shell queue on the loaded manager class', function (): void {
$reflection = new \ReflectionClass(edge_gateway_manager::class);
it('loads relay command helpers on the manager and gateway operations on the dedicated service', function (): void {
$reflection = new ReflectionClass(edge_gateway_manager::class);
$operationServiceReflection = new ReflectionClass(\classes\edge_gateway_operation_service::class);
expect($reflection->hasMethod('requireDispatchableGateway'))->toBeTrue();
expect($reflection->getMethod('requireDispatchableGateway')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('queueDiscovery'))->toBeTrue();
expect($reflection->hasMethod('queueUpdate'))->toBeTrue();
expect($reflection->hasMethod('queueUninstall'))->toBeTrue();
expect($reflection->hasMethod('deleteGateway'))->toBeTrue();
expect($reflection->hasMethod('buildUpdateCommandPayload'))->toBeTrue();
expect($reflection->hasMethod('applyHeartbeatUpdateLifecycle'))->toBeTrue();
expect($reflection->hasMethod('pollCommand'))->toBeTrue();
expect($reflection->hasMethod('submitCommandResult'))->toBeTrue();
expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue();
expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue();
expect($reflection->hasMethod('pollShellAction'))->toBeTrue();
expect($reflection->hasMethod('submitShellActionResult'))->toBeTrue();
expect($reflection->hasMethod('recordShellSessionEvents'))->toBeTrue();
expect($reflection->hasMethod('pollShellSessionEvents'))->toBeTrue();
expect($reflection->hasMethod('enqueueShellInput'))->toBeTrue();
expect($reflection->hasMethod('enqueueShellResize'))->toBeTrue();
expect($reflection->hasMethod('requestShellSessionClose'))->toBeTrue();
expect($reflection->hasMethod('claimNextCommandJob'))->toBeTrue();
expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue();
expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue();
expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue();
expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue();
expect($operationServiceReflection->hasMethod('completeAgentOperation'))->toBeTrue();
});
@@ -68,7 +68,7 @@ it('merges incoming heartbeat metadata with existing gateway metadata', function
expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));");
});
it('derives relay fallback and transport health details for degraded hybrid control planes', function (): void {
it('derives relay fallback and transport health details without shell or update runtime state', function (): void {
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
'status' => edge_gateway_manager::STATUS_ONLINE,
'last_heartbeat_at' => '2026-04-08 10:04:30',
@@ -82,8 +82,13 @@ it('derives relay fallback and transport health details for degraded hybrid cont
],
'operational_snapshot' => [
'command_backlog' => 2,
'shell_backlog' => 1,
'update_backlog' => 1,
'operation_backlog' => 1,
],
'active_operation' => [
'id' => 91,
'type' => 'DISCOVERY',
'status' => 'IN_PROGRESS',
'started_at' => '2026-04-08 09:30:00',
],
'bindings' => [
[
@@ -113,15 +118,11 @@ it('derives relay fallback and transport health details for degraded hybrid cont
'completed_at' => '2026-04-08 10:02:00',
],
],
'recent_shell_sessions' => [
[
'opened_at' => '2026-04-08 10:03:00',
],
],
], strtotime('2026-04-08 10:05:00'));
expect($gateway['channel_status']['command']['active'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API);
expect($gateway['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
expect($gateway['channel_status'])->not->toHaveKey('shell');
expect($gateway['relay_health'][0]['execution_path'])->toBe('cloud');
expect($gateway['relay_health'][0]['reason'])->toBe('device_stale');
expect($gateway['relay_health'][0]['recommended_action'])->toBe('retry_discovery');
@@ -132,10 +133,10 @@ it('derives relay fallback and transport health details for degraded hybrid cont
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
expect($gateway['last_successful_shell_at'])->toBe('2026-04-08 10:03:00');
expect($gateway['diagnostics'])->not->toBeEmpty();
expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT');
expect($gateway['backlog_depth'])->toBe([
'commands' => 2,
'shell_actions' => 1,
'updates' => 1,
'operations' => 1,
]);
});
@@ -15,7 +15,6 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
{
$originalServer = $_SERVER;
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
$originalPublicBrokerUrl = getenv('EDGE_PUBLIC_BROKER_URL');
$_SERVER = $server;
@@ -28,15 +27,10 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
} else {
putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl);
}
if ($originalPublicBrokerUrl === false) {
putenv('EDGE_PUBLIC_BROKER_URL');
} else {
putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl);
}
}
}
it('builds install script urls with forwarded https scheme when proxied', function (): void {
it('builds install script urls with the PHP agent artifacts and forwarded https scheme', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'HTTP_X_FORWARDED_PROTO' => 'https',
@@ -48,18 +42,17 @@ it('builds install script urls with forwarded https scheme when proxied', functi
expect($manager->buildInstallTokenVerifyUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123');
expect($manager->buildInstallScriptUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123');
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123" >/dev/null');
expect($script)->toContain('mkdir -p "$INSTALL_DIR"');
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/package.json" -o "$INSTALL_DIR/package.json"');
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/agent.mjs" -o "$INSTALL_DIR/agent.mjs"');
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
expect($script)->toContain('fetch_http "Download systemd service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service" "$INSTALL_DIR/truckwash-edge-agent.service"');
expect($script)->toContain('Installer failed during step: ${CURRENT_STEP:-unknown}');
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
expect($script)->toContain('Response body preview (first 400 bytes):');
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
expect($script)->toContain('"installDir":"/opt/truckwash-edge-agent"');
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
expect($script)->toContain('"commandPollTimeoutSeconds":20');
expect($script)->toContain('"shellActionPollTimeoutSeconds":20');
expect($script)->toContain('"updateVerificationTimeoutSeconds":45');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
expect($script)->not->toContain('Undefined variable $INSTALL_DIR');
expect($script)->toContain('"operationPollTimeoutSeconds":20');
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
expect($script)->not->toContain('"brokerUrl"');
});
});
@@ -75,47 +68,19 @@ it('appends forwarded ports when the forwarded host omits them', function (): vo
});
});
it('infers https for the staging api host when only the https port is present', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'SERVER_PORT' => '4433',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
$script = $manager->buildInstallScript('port-only');
expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433');
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
});
});
it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
], function (): void {
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
putenv('EDGE_PUBLIC_BROKER_URL=https://broker.edge.example.test');
$manager = new EdgeGatewayManagerUrlHarness();
$script = $manager->buildInstallScript('token-1');
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
expect($manager->buildInstallScript('token-1'))->toContain('"brokerUrl":"https://broker.edge.example.test"');
});
});
it('includes node-pty build prerequisites in the generated install script', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'HTTP_X_FORWARDED_PROTO' => 'https',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
$script = $manager->buildInstallScript('pty-token');
expect($script)->toMatch('/set -euo pipefail\s+curl -fsSL "https:\/\/api\.truckwash\.io:4433\/edge-agent\/install-token\/verify\?token=pty-token" >\/dev\/null\s+INSTALL_DIR=\/opt\/truckwash-edge-agent/');
expect($script)->toContain('apt-get install -y curl ca-certificates nodejs npm python3 make g++');
expect($script)->toContain('npm install --omit=dev');
expect($script)->toContain('"apiUrl":"https://edge.example.test/api"');
expect($script)->not->toContain('"brokerUrl"');
});
});
@@ -0,0 +1,29 @@
<?php
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('adds a gateway metadata update endpoint with label and primary assignment fields', function (): void {
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($routeSource)->not->toBeFalse();
expect($routeSource)->toContain("put('/edge-gateways/{id}'");
expect($routeSource)->toContain("self::requireParameters(['label', 'is_primary']);");
expect($routeSource)->toContain("self::requireType(self::getParameter('label'), self::TYPE_STRING());");
expect($routeSource)->toContain("self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());");
expect($routeSource)->toContain('updateGatewayMetadata(');
});
it('reassigns department primary gateways through dedicated manager helpers', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$reflection = new ReflectionClass(edge_gateway_manager::class);
expect($source)->not->toBeFalse();
expect($source)->toContain("\$this->setGatewayPrimaryState(\$gateway, true);");
expect($source)->toContain("\$replacement = \$this->findAlternateGatewayForDepartment(");
expect($source)->toContain("throw new Exception('Department must retain a primary gateway');");
expect($reflection->hasMethod('updateGatewayMetadata'))->toBeTrue();
expect($reflection->hasMethod('setGatewayPrimaryState'))->toBeTrue();
expect($reflection->hasMethod('findAlternateGatewayForDepartment'))->toBeTrue();
});
@@ -0,0 +1,17 @@
<?php
it('keeps the legacy operation_type column compatible with v2 operation queueing', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
$operationServiceContent = file_get_contents(app_path('classes/edge_gateway_operation_service.php'));
$operationObjectContent = file_get_contents(app_path('objects/edge_gateway_operations_o.php'));
expect($bootstrapContent)->not->toBeFalse();
expect($operationServiceContent)->not->toBeFalse();
expect($operationObjectContent)->not->toBeFalse();
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'operation_type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type\")");
expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void');
expect($bootstrapContent)->toContain('SET type = operation_type');
expect($operationServiceContent)->toContain("'operation_type' => \$type");
expect($operationObjectContent)->toContain("new object_property(\$this->table, \$this->id, 'operation_type', 'string', false)");
});
@@ -1,6 +1,6 @@
<?php
it('registers the edge gateway management REST endpoints', function (): void {
it('registers the v2 operator-facing edge gateway routes', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->not->toBeFalse();
@@ -9,42 +9,32 @@ it('registers the edge gateway management REST endpoints', function (): void {
expect($route)->toContain("'/edge-gateways/install-token'");
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
expect($route)->toContain("'/edge-gateways/{id}/update-jobs'");
expect($route)->toContain("'/edge-gateways/{id}/uninstall'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
expect($route)->toContain("'/edge-gateways/{id}/operations'");
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'");
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($route)->toContain("'/edge-gateways/{id}'");
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
expect($route)->toContain("add_meta('fleet_usage'");
expect($route)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($route)->not->toContain('private function requirePermission');
expect($route)->not->toContain('private function requireDepartmentAccess');
});
it('registers public installer, claim, heartbeat, command polling, and shell polling endpoints', function (): void {
it('registers PHP edge agent routes for operations and legacy relay command polling', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->toContain("'/edge-agent/install-token/verify'");
expect($route)->toContain("'/edge-agent/install.sh'");
expect($route)->toContain("'/edge-agent/artifacts/agent.mjs'");
expect($route)->toContain("'/edge-agent/artifacts/package.json'");
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-agent.service'");
expect($route)->toContain("'/edge-agent/claim'");
expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'");
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/next'");
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/{operationId}/events'");
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/{operationId}/complete'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
});
it('registers authenticated internal broker validation and presence endpoints', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/validate'");
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/close'");
expect($route)->toContain('HTTP_X_EDGE_BROKER_SECRET');
expect($route)->toContain('EDGE_BROKER_SHARED_SECRET');
expect($route)->toContain("'/edge-agent/gateways/{id}/presence'");
expect($route)->toContain('echo $exception->getMessage()');
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
});
@@ -1,6 +1,6 @@
<?php
it('defines the edge gateway runtime schema bootstrap tables', function (): void {
it('defines the v2 edge gateway schema bootstrap tables', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
expect($bootstrapContent)->not->toBeFalse();
@@ -9,24 +9,38 @@ it('defines the edge gateway runtime schema bootstrap tables', function (): void
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_device_inventory');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_relay_bindings');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_command_jobs');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_action_jobs');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_events');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_sessions');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_action_jobs');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_events');
});
it('stores edge gateway heartbeats, bindings, shell transcripts, and shell polling queues', function (): void {
it('stores operation metadata and event timelines for management workflows', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
expect($bootstrapContent)->toContain('last_heartbeat_at DATETIME NULL');
expect($bootstrapContent)->toContain('command_job_id INT NULL');
expect($bootstrapContent)->toContain('channel INT NOT NULL DEFAULT 0');
expect($bootstrapContent)->toContain('transcript_text LONGTEXT NULL');
expect($bootstrapContent)->toContain('action_type VARCHAR(32) NOT NULL');
expect($bootstrapContent)->toContain('payload_json JSON NULL');
expect($bootstrapContent)->toContain('event_type VARCHAR(32) NOT NULL');
expect($bootstrapContent)->toContain('command_type VARCHAR(64) NOT NULL');
expect($bootstrapContent)->toContain('delivery_json JSON NULL');
expect($bootstrapContent)->toContain("fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL'");
expect($bootstrapContent)->toContain('type VARCHAR(32) NOT NULL');
expect($bootstrapContent)->toContain("operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY'");
expect($bootstrapContent)->toContain('agent_instance_id VARCHAR(128) NULL');
expect($bootstrapContent)->toContain('lease_expires_at DATETIME NULL');
expect($bootstrapContent)->toContain('last_progress_at DATETIME NULL');
expect($bootstrapContent)->toContain('attempt_count INT NOT NULL DEFAULT 0');
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id\")");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'operation_type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type\")");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'summary_json', 'JSON NULL AFTER request_json')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'agent_instance_id', 'VARCHAR(128) NULL AFTER correlation_id')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'lease_expires_at', 'DATETIME NULL AFTER agent_instance_id')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'last_progress_at', 'DATETIME NULL AFTER lease_expires_at')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at')");
expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void');
expect($bootstrapContent)->toContain('SET type = operation_type');
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')");
expect($bootstrapContent)->toContain('summary_json JSON NULL');
expect($bootstrapContent)->toContain('context_json JSON NULL');
expect(substr_count($bootstrapContent, 'delivery_json JSON NULL'))->toBeGreaterThanOrEqual(3);
expect($bootstrapContent)->toContain('ADD COLUMN delivery_json JSON NULL');
expect($bootstrapContent)->not->toContain('session_token_hash CHAR(64) NOT NULL');
});
@@ -1,36 +1,15 @@
<?php
it('rewrites edge gateway shell transport to API polling queues', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
it('removes shell access from the edge gateway HTTP contracts', function (): void {
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($managerSource)->not->toBeFalse();
expect($routeSource)->not->toBeFalse();
expect($managerSource)->toContain("'transport' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain("'transport_path' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain("'reconnect_state' => 'PENDING'");
expect($managerSource)->toContain("'preferred_channel' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain('private function createShellActionJob');
expect($managerSource)->toContain('private function claimNextShellActionJob');
expect($managerSource)->toContain('private function appendShellEvent');
expect($managerSource)->toContain('private function listShellEvents');
expect($managerSource)->toContain('public function pollShellAction');
expect($managerSource)->toContain('public function submitShellActionResult');
expect($managerSource)->toContain('public function recordShellSessionEvents');
expect($managerSource)->not->toContain('buildBrowserShellWsUrl');
expect($managerSource)->not->toContain('edge_broker_client');
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/validate'");
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/close'");
expect($routeSource)->toContain('requireBrokerSharedSecret');
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
});
@@ -1,22 +1,43 @@
<?php
it('builds update command payloads with artifact urls, checksums, and the service name', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
it('builds the installer around the PHP agent artifacts and management polling config', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$serviceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'));
expect($source)->not->toBeFalse();
expect($source)->toContain("'artifactUrl' => \$this->buildAgentArtifactUrl('agent.mjs')");
expect($source)->toContain("'artifactSha256' => \$this->buildAgentArtifactSha256('agent.mjs')");
expect($source)->toContain("'packageUrl' => \$this->buildAgentArtifactUrl('package.json')");
expect($source)->toContain("'packageSha256' => \$this->buildAgentArtifactSha256('package.json')");
expect($source)->toContain("'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME");
expect($managerSource)->not->toBeFalse();
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
expect($managerSource)->toContain('fetch_http "Download systemd service unit" "__SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"');
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
expect($managerSource)->toContain('apt-get install -y curl ca-certificates php-cli php-curl php-mbstring');
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
expect($managerSource)->toContain('systemctl enable truckwash-edge-agent.service');
expect($managerSource)->toContain('systemctl restart truckwash-edge-agent.service');
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-agent.service');
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
expect($managerSource)->toContain('journalctl -u truckwash-edge-agent.service -n 40 --no-pager || true');
expect($managerSource)->not->toContain('agent.mjs');
expect($managerSource)->not->toContain('"brokerUrl"');
expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json');
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
});
it('finalizes active update jobs from heartbeat metadata when the agent reports completion or rollback', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
it('exposes update payload, credential rotation, and operation endpoints without shell transport wiring', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
expect($source)->toContain("\$state = strtoupper(trim((string)(\$lastUpdate['state'] ?? '')));");
expect($source)->toContain("if (!in_array(\$state, ['COMPLETED', 'FAILED', 'ROLLED_BACK'], true)) {");
expect($source)->toContain("\$updateJob->status->set(\$state);");
expect($source)->toContain("'heartbeat_installed_version' => \$payload['installed_version'] ?? \$gateway->installed_version->value()");
expect($source)->toContain("'heartbeat_target_version' => \$payload['target_version'] ?? \$gateway->target_version->value()");
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
expect($managerSource)->toContain('public function rotateGatewayCredentials');
expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'");
expect($agentSource)->toContain("/operations/next");
expect($agentSource)->toContain("/operations/' . \$operationId . '/complete");
expect($agentSource)->toContain('last-heartbeat-ok.txt');
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
});
@@ -108,7 +108,7 @@ it('calculates workfeed employee hours across multiple departments for one hour
expect($hours)->toBe(2.0);
});
it('counts overtime minutes when a shift carries an extended approval end timestamp', function (): void {
it('counts overtime minutes when a saved shift end extends past the approved original end', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-23T18:00:00+00:00');
@@ -116,9 +116,9 @@ it('counts overtime minutes when a shift carries an extended approval end timest
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-23T10:00:00+00:00',
'end' => '2026-03-23T18:00:00+00:00',
'end' => '2026-03-23T18:17:00+00:00',
'approval' => (object)[
'originalEnd' => '2026-03-23T18:17:00+00:00',
'originalEnd' => '2026-03-23T18:00:00+00:00',
],
],
];
@@ -128,6 +128,26 @@ it('counts overtime minutes when a shift carries an extended approval end timest
expect($hours)->toBe(0.28);
});
it('does not count removed approved time when a saved shift end is shortened', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-23T17:00:00+00:00');
$shifts = [
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-23T10:00:00+00:00',
'end' => '2026-03-23T17:00:00+00:00',
'approval' => (object)[
'originalEnd' => '2026-03-23T18:00:00+00:00',
],
],
];
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
expect($hours)->toBe(0.0);
});
it('counts unapproved overtime from a bounded shift updateTime fallback', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-23T17:00:00+00:00');
@@ -166,6 +186,27 @@ it('does not treat late unapproved administrative edits as overtime', function (
expect($hours)->toBe(0.0);
});
it('caps current-slot hours to elapsed minutes and zeroes future slots', function (): void {
$route = new moduleWeatherAPIRoute();
$currentSlot = new DateTime('2026-03-24T13:00:00+00:00');
$futureSlot = new DateTime('2026-03-24T14:00:00+00:00');
$occurredUntil = new DateTime('2026-03-24T13:15:00+00:00');
$shifts = [
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-24T13:00:00+00:00',
'end' => '2026-03-24T15:00:00+00:00',
],
];
$currentHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $currentSlot, $occurredUntil]);
$futureHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $futureSlot, $occurredUntil]);
expect($currentHours)->toBe(0.25);
expect($futureHours)->toBe(0.0);
});
it('normalizes department id input from scalar csv and nested array values', function (): void {
$route = new moduleWeatherAPIRoute();
@@ -37,7 +37,7 @@ trait module_config_t
{
// Check if the request has a variable name and value
global $response;
if ($response->getRequestParameter('variable') === null || $response->getRequestParameter('value') === null) {
if ($response->getRequestParameter('variable') === null || !$response->isRequestParameterSet('value')) {
$response->error('Variable and value not set', 400);
}
// Get the variable name and value
@@ -144,6 +144,9 @@ trait module_config_t
{
// If the variable is an int, convert it to an int
if ($type == 'integer' || $type == 'int') {
if ($value === null || $value === '') {
return null;
}
return (integer)$value;
}
// If the variable is a boolean, convert it to a boolean
@@ -171,6 +171,14 @@ trait module_config_variable
*/
function validateVariableValue(mixed $value): bool
{
if ($value === null) {
return !$this->config_variable_required;
}
if ($value === '' && !$this->config_variable_required && $this->config_variable_type === 'int') {
return true;
}
// Check if the value is empty and the variable is required
if ($this->config_variable_required && empty($value)) {
// If the value is empty and the type is not a boolean, return false
+1
View File
@@ -4,3 +4,4 @@
[24-Mar-2026 10:12:39 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
[01-Apr-2026 10:49:51 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
[07-Apr-2026 14:28:09 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
[20-Apr-2026 12:45:29 UTC] PHP Parse error: syntax error, unexpected end of file in Command line code on line 1