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:
@@ -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"]
|
||||
Vendored
+9
-34
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+37
-17
@@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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();');
|
||||
});
|
||||
+35
-4
@@ -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();
|
||||
});
|
||||
+17
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user