Add unit tests for invoicing, orders normalization, gateway commands, and department complaints. Update schema bootstraps and improve agent command execution logic.

This commit is contained in:
Jeppe Bundgaard
2026-04-09 12:24:22 +02:00
parent 9e9372db05
commit 22f856c62a
35 changed files with 2346 additions and 167 deletions
+516 -11
View File
@@ -901,14 +901,16 @@ paths:
summary: Process inbound Bird voice call lifecycle summary: Process inbound Bird voice call lifecycle
operationId: birdInboundVoiceCallWebhook operationId: birdInboundVoiceCallWebhook
description: > description: >
Stateful inbound-call webhook that answers the call immediately, tracks DTMF input, and enforces a timeout hangup flow. Stateful inbound-call webhook that answers the call immediately, runs a DTMF-driven IVR for
During the first 300 seconds from call start, DTMF input is extracted from fixed payload fields phone-controlled entrance and exit gates, and enforces a timeout hangup flow. During the
(`dtmf`, `digit`, `digits`, `keys`, and known nested variants). A Slack message is sent whenever any DTMF first 300 seconds from call start, DTMF input is extracted from payload fields such as
value is captured, the call acknowledges input, and gather is re-issued with retry-loop semantics `dtmf`, `digit`, `digits`, `keys`, `result.keys`, and nested `conditions[].value`. Values
checking for input every 2 seconds until a digit is entered. At or after 300 seconds, the webhook like `1#` are normalized to a single menu digit before the route resolves the department
says `timeout reached`, waits 10 selection and then the gate type selection (`1=entrance`, `2=exit`). When a valid gate is
seconds, sends a hangup command once, and polls Bird call status until terminal. Call lifecycle resolved, the webhook triggers the corresponding `department_gates` phone-call gate, announces
state is only finalized and cleared after terminal status is observed. the result, sends a hangup command, and keeps the completed state cached until Bird reports a
terminal call status. At or after 300 seconds, the webhook says `timeout reached`, waits 10
seconds, sends a hangup command once, and polls Bird call status until terminal.
parameters: parameters:
- in: query - in: query
name: workspaceId name: workspaceId
@@ -9878,6 +9880,33 @@ paths:
'403': '403':
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
/superuser/system/status:
get:
tags:
- Superuser
summary: Aggregated system status snapshot
description: Returns a read-only snapshot of runtime health, dependency connectivity, module configuration/probe status, and active user session activity for the superuser dashboard.
operationId: getSuperuserSystemStatus
parameters:
- in: query
name: force
required: false
schema:
type: boolean
default: false
description: Bypass cached external module probes for this request.
responses:
'200':
description: System status snapshot returned successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuperuserSystemStatusResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
# Configuration Endpoints # Configuration Endpoints
/economic/config: /economic/config:
get: get:
@@ -10938,6 +10967,127 @@ paths:
application/json: application/json:
schema: {} schema: {}
/departments/daily-reports/complaints:
get:
tags:
- Departments
summary: List or fetch daily report customer complaints
operationId: listDailyReportComplaints
parameters:
- name: id
in: query
required: false
schema: {type: integer}
- name: page
in: query
required: false
schema: {type: integer}
- name: limit
in: query
required: false
schema: {type: integer}
- name: search
in: query
required: false
schema: {type: string}
- name: filters
in: query
required: false
schema: {type: string}
- name: order
in: query
required: false
schema: {type: string}
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCollectionResponse'
post:
tags:
- Departments
summary: Create daily report customer complaint
operationId: createDailyReportComplaint
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCreateRequest'
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintResponse'
put:
tags:
- Departments
summary: Update daily report customer complaint
operationId: updateDailyReportComplaint
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintUpdateRequest'
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintResponse'
delete:
tags:
- Departments
summary: Delete daily report customer complaint
operationId: deleteDailyReportComplaint
parameters:
- name: id
in: query
required: true
schema: {type: integer}
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintDeleteResponse'
/departments/daily-reports/complaints/customers:
get:
tags:
- Departments
summary: Search selectable customers for daily report complaints
operationId: searchDailyReportComplaintCustomers
parameters:
- name: search
in: query
required: true
schema:
type: string
minLength: 2
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 20
default: 10
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResponse'
/departments/daily-reports/product-count: /departments/daily-reports/product-count:
get: get:
tags: tags:
@@ -11262,6 +11412,245 @@ components:
type: integer type: integer
description: HTTP status code description: HTTP status code
SuperuserSystemStatusResponse:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/SuperuserSystemStatusPayload'
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
SuperuserSystemStatusPayload:
type: object
properties:
overall_status:
$ref: '#/components/schemas/SuperuserSystemStatusEnum'
generated_at:
type: string
format: date-time
refresh_after_seconds:
type: integer
runtime:
type: object
properties:
cpu:
$ref: '#/components/schemas/SuperuserRuntimeMetric'
memory:
$ref: '#/components/schemas/SuperuserRuntimeMetric'
disk:
$ref: '#/components/schemas/SuperuserRuntimeMetric'
dependencies:
type: object
properties:
database:
$ref: '#/components/schemas/SuperuserDependencyStatus'
redis:
$ref: '#/components/schemas/SuperuserDependencyStatus'
minio:
$ref: '#/components/schemas/SuperuserMinioDependencyStatus'
modules:
type: array
items:
$ref: '#/components/schemas/SuperuserModuleStatus'
sessions:
$ref: '#/components/schemas/SuperuserSessionStatus'
warnings:
type: array
items:
type: string
required:
- overall_status
- generated_at
- refresh_after_seconds
- runtime
- dependencies
- modules
- sessions
- warnings
SuperuserSystemStatusEnum:
type: string
enum: [ok, degraded, down]
SuperuserModuleStatusEnum:
type: string
enum: [disabled, not_configured, configured, ok, degraded, down]
SuperuserRuntimeMetric:
type: object
properties:
status:
$ref: '#/components/schemas/SuperuserSystemStatusEnum'
usage_percent:
type: number
format: float
nullable: true
used_bytes:
type: integer
nullable: true
free_bytes:
type: integer
nullable: true
total_bytes:
type: integer
nullable: true
path:
type: string
nullable: true
source:
type: string
nullable: true
checked_at:
type: string
format: date-time
SuperuserDependencyStatus:
type: object
properties:
status:
$ref: '#/components/schemas/SuperuserSystemStatusEnum'
latency_ms:
type: number
format: float
nullable: true
database:
oneOf:
- type: integer
- type: string
nullable: true
server_version:
type: string
nullable: true
http_status:
type: integer
nullable: true
checked_at:
type: string
format: date-time
error:
type: string
nullable: true
SuperuserMinioDependencyStatus:
type: object
properties:
status:
$ref: '#/components/schemas/SuperuserSystemStatusEnum'
latency_ms:
type: number
format: float
nullable: true
endpoint:
type: string
nullable: true
http_status:
type: integer
nullable: true
buckets:
type: array
items:
type: object
properties:
name:
type: string
status:
type: string
error:
type: string
nullable: true
checked_at:
type: string
format: date-time
error:
type: string
nullable: true
SuperuserModuleStatus:
type: object
properties:
key:
type: string
enabled:
type: boolean
configured:
type: boolean
probe_supported:
type: boolean
status:
$ref: '#/components/schemas/SuperuserModuleStatusEnum'
status_reason:
type: string
nullable: true
checked_at:
type: string
format: date-time
required:
- key
- enabled
- configured
- probe_supported
- status
- checked_at
SuperuserSessionStatus:
type: object
properties:
active_window_minutes:
type: integer
active_users:
type: integer
active_sessions:
type: integer
recent_sessions:
type: array
items:
type: object
properties:
session_kind:
type: string
principal_id:
type: integer
display_name:
type: string
context_label:
type: string
nullable: true
customer_number_context:
type: integer
nullable: true
device_type:
type: string
user_agent:
type: string
last_route:
type: string
first_seen_at:
type: string
format: date-time
nullable: true
last_seen_at:
type: string
format: date-time
nullable: true
active:
type: boolean
required:
- active_window_minutes
- active_users
- active_sessions
- recent_sessions
SystemSearchEntityType: SystemSearchEntityType:
type: string type: string
enum: enum:
@@ -16077,16 +16466,20 @@ components:
example: ongoing example: ongoing
dtmf: dtmf:
type: string type: string
description: DTMF value when present description: DTMF value when present, for example `1` or `1#`
example: "5" example: "5"
digit: digit:
type: string type: string
description: Alternate DTMF field description: Alternate DTMF field, also accepts values such as `1#`
example: "5" example: "5"
digits: digits:
type: string type: string
description: Alternate DTMF field description: Alternate DTMF field
example: "5" example: "5"
keys:
type: string
description: Alternate DTMF field returned by gather results
example: "1#"
call: call:
type: object type: object
additionalProperties: true additionalProperties: true
@@ -16110,7 +16503,11 @@ components:
properties: properties:
phase: phase:
type: string type: string
enum: [lock_not_acquired, input_window, timeout_window, terminal_completion] enum: [lock_not_acquired, input_window, timeout_window, completed, terminal_completion]
stage:
type: string
nullable: true
enum: [department_select, gate_type_select, completed]
call_id: call_id:
type: string type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
@@ -16131,6 +16528,19 @@ components:
last_input: last_input:
type: string type: string
nullable: true nullable: true
selected_department_id:
type: integer
nullable: true
selected_gate_type:
type: string
nullable: true
enum: [entrance, exit]
gate_id:
type: integer
nullable: true
gate_opened:
type: boolean
nullable: true
answered_at: answered_at:
type: integer type: integer
nullable: true nullable: true
@@ -16616,6 +17026,101 @@ components:
type: array type: array
items: { type: integer } items: { type: integer }
DepartmentDailyReportComplaintCreateRequest:
type: object
required: [department_id, description]
properties:
department_id: { type: integer }
customer_number:
type: integer
nullable: true
description:
type: string
minLength: 1
maxLength: 4000
DepartmentDailyReportComplaintUpdateRequest:
type: object
required: [id]
properties:
id: { type: integer }
department_id: { type: integer }
customer_number:
type: integer
nullable: true
description:
type: string
minLength: 1
maxLength: 4000
DepartmentDailyReportComplaintCustomerSearchResult:
type: object
properties:
customer_number:
type: integer
customer_name:
type: string
nullable: true
DepartmentDailyReportComplaint:
type: object
properties:
id: { type: integer }
department_id: { type: integer }
department_name:
type: string
nullable: true
customer_number:
type: integer
nullable: true
customer_name:
type: string
nullable: true
description: { type: string }
created_by: { type: integer }
created_by_name:
type: string
nullable: true
created_at:
type: string
format: date-time
DepartmentDailyReportComplaintResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportComplaint'
DepartmentDailyReportComplaintCollectionResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
oneOf:
- $ref: '#/components/schemas/DepartmentDailyReportComplaint'
- type: array
items:
$ref: '#/components/schemas/DepartmentDailyReportComplaint'
DepartmentDailyReportComplaintCustomerSearchResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
type: array
items:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResult'
DepartmentDailyReportComplaintDeleteResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
type: object
properties:
message: { type: string }
DepartmentDailyReportProductTile: DepartmentDailyReportProductTile:
type: object type: object
properties: properties:
+220 -44
View File
@@ -1,23 +1,22 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import os from "node:os"; import os from "node:os";
import { spawn } from "node:child_process";
import process from "node:process"; import process from "node:process";
import WebSocket from "ws"; import WebSocket from "ws";
const DEFAULT_VERSION = "0.1.0"; const DEFAULT_VERSION = "0.1.0";
const DEFAULT_RECONNECT_DELAY_MS = 5000; const DEFAULT_RECONNECT_DELAY_MS = 5000;
const DEFAULT_SHELL_COLS = 120;
const DEFAULT_SHELL_ROWS = 32;
function buildBrokerHeartbeatState(isConnected) { function buildBrokerHeartbeatState(isConnected) {
return isConnected return isConnected
? { ? {
status: "ONLINE", status: "ONLINE",
discovery_status: "READY",
metadata: { broker_connected: true }, metadata: { broker_connected: true },
} }
: { : {
status: "DEGRADED", status: "DEGRADED",
discovery_status: "BROKER_DISCONNECTED",
metadata: { broker_connected: false }, metadata: { broker_connected: false },
}; };
} }
@@ -219,52 +218,123 @@ function defaultShellCommand() {
return { command: process.env.SHELL || "/bin/sh", args: [] }; return { command: process.env.SHELL || "/bin/sh", args: [] };
} }
export function createShellBridge(sendMessage) { function normalizeShellSize(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : fallback;
}
async function createDefaultPtyProcess(options = {}) {
const nodePtyModule = await import("node-pty");
const spawnPty =
nodePtyModule.spawn ??
nodePtyModule.default?.spawn ??
nodePtyModule.default;
if (typeof spawnPty !== "function") {
throw new Error("node-pty spawn is unavailable");
}
return spawnPty(options.command, options.args || [], {
name: options.env?.TERM || "xterm-256color",
cols: normalizeShellSize(options.cols, DEFAULT_SHELL_COLS),
rows: normalizeShellSize(options.rows, DEFAULT_SHELL_ROWS),
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
});
}
export function createShellBridge(sendMessage, { createPtyProcess = createDefaultPtyProcess } = {}) {
const sessions = new Map(); const sessions = new Map();
const open = (payload = {}) => { const open = async (payload = {}) => {
const sessionId = String(payload.sessionId); const sessionId = String(payload.sessionId || "");
const shell = payload.shellCommand ? { command: payload.shellCommand, args: payload.shellArgs || [] } : defaultShellCommand(); if (sessionId === "") {
const proc = spawn(shell.command, shell.args, { return;
cwd: payload.cwd || process.cwd(), }
env: process.env,
stdio: "pipe",
});
proc.stdout.on("data", (chunk) => { const existingSession = sessions.get(sessionId);
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: chunk.toString("utf8") }); if (existingSession) {
}); existingSession.pty.kill();
proc.stderr.on("data", (chunk) => {
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: chunk.toString("utf8") });
});
proc.on("close", (code) => {
sessions.delete(sessionId); sessions.delete(sessionId);
sendMessage({ type: "SHELL_EXIT", sessionId, code }); }
});
sessions.set(sessionId, proc); const shell = payload.shellCommand
sendMessage({ type: "SHELL_OPENED", sessionId }); ? { command: payload.shellCommand, args: payload.shellArgs || [] }
: defaultShellCommand();
try {
const pty = await Promise.resolve(createPtyProcess({
command: shell.command,
args: shell.args,
cwd: payload.cwd || process.cwd(),
cols: normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS),
rows: normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS),
env: {
...process.env,
TERM: payload.term || process.env.TERM || "xterm-256color",
},
}));
const sessionRecord = {
pty,
dataSubscription: null,
exitSubscription: null,
};
sessionRecord.dataSubscription = pty.onData((data) => {
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: String(data || "") });
});
sessionRecord.exitSubscription = pty.onExit(({ exitCode }) => {
sessions.delete(sessionId);
sessionRecord.dataSubscription?.dispose?.();
sessionRecord.exitSubscription?.dispose?.();
sendMessage({ type: "SHELL_EXIT", sessionId, code: Number.isFinite(exitCode) ? exitCode : 0 });
});
sessions.set(sessionId, sessionRecord);
sendMessage({ type: "SHELL_OPENED", sessionId });
} catch (error) {
sendMessage({
type: "SHELL_OUTPUT",
sessionId,
data: `Failed to start root shell: ${error instanceof Error ? error.message : String(error)}\r\n`,
});
sendMessage({ type: "SHELL_EXIT", sessionId, code: 1 });
}
}; };
const input = (payload = {}) => { const input = (payload = {}) => {
const proc = sessions.get(String(payload.sessionId)); const sessionRecord = sessions.get(String(payload.sessionId || ""));
if (!proc) { if (!sessionRecord) {
return; return;
} }
proc.stdin.write(String(payload.data || "")); sessionRecord.pty.write(String(payload.data || ""));
};
const resize = (payload = {}) => {
const sessionRecord = sessions.get(String(payload.sessionId || ""));
if (!sessionRecord) {
return;
}
sessionRecord.pty.resize(
normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS),
normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS)
);
}; };
const close = (payload = {}) => { const close = (payload = {}) => {
const sessionId = String(payload.sessionId); const sessionId = String(payload.sessionId || "");
const proc = sessions.get(sessionId); const sessionRecord = sessions.get(sessionId);
if (!proc) { if (!sessionRecord) {
return; return;
} }
proc.kill();
sessions.delete(sessionId); sessionRecord.pty.kill();
}; };
return { open, input, close }; return { open, input, resize, close };
} }
export async function handleAgentCommand(command, deps = {}) { export async function handleAgentCommand(command, deps = {}) {
@@ -288,18 +358,26 @@ export async function handleAgentCommand(command, deps = {}) {
} }
export function buildHeartbeatPayload(config, extra = {}) { export function buildHeartbeatPayload(config, extra = {}) {
return { const payload = {
hostname: os.hostname(), hostname: os.hostname(),
installed_version: config.installedVersion || DEFAULT_VERSION, installed_version: config.installedVersion || DEFAULT_VERSION,
target_version: config.targetVersion || config.installedVersion || DEFAULT_VERSION, target_version: config.targetVersion || config.installedVersion || DEFAULT_VERSION,
status: extra.status || "ONLINE", status: extra.status || "ONLINE",
discovery_status: extra.discovery_status || "READY",
inventory: extra.inventory || [],
metadata: { metadata: {
release_channel: config.releaseChannel || "stable", release_channel: config.releaseChannel || "stable",
...extra.metadata, ...extra.metadata,
}, },
}; };
if (Object.prototype.hasOwnProperty.call(extra, "discovery_status")) {
payload.discovery_status = extra.discovery_status;
}
if (Array.isArray(extra.inventory)) {
payload.inventory = extra.inventory;
}
return payload;
} }
export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) { export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) {
@@ -318,17 +396,80 @@ export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) {
); );
} }
export async function pollCommandJob(config, fetchImpl = fetch, waitSeconds = 20) {
if (!config.gatewayId || !config.agentToken) {
throw new Error("Gateway claim must complete before polling commands");
}
return apiRequest(
config.apiUrl,
`/edge-agent/gateways/${config.gatewayId}/commands/poll`,
"POST",
{
agent_token: config.agentToken,
wait_seconds: waitSeconds,
},
fetchImpl
);
}
export async function submitCommandJobResult(config, jobId, { ok, payload = {}, error = null } = {}, fetchImpl = fetch) {
if (!config.gatewayId || !config.agentToken) {
throw new Error("Gateway claim must complete before submitting command results");
}
return apiRequest(
config.apiUrl,
`/edge-agent/gateways/${config.gatewayId}/commands/${jobId}/result`,
"POST",
{
agent_token: config.agentToken,
ok: Boolean(ok),
payload,
error,
},
fetchImpl
);
}
export async function processPolledCommand(config, command, fetchImpl = fetch) {
const jobId = command?.id;
const commandType = command?.commandType || command?.command_type;
const payload = command?.payload || {};
if (!jobId || !commandType) {
return null;
}
try {
const result = await handleAgentCommand({ commandType, payload }, { fetchImpl });
await submitCommandJobResult(config, jobId, {
ok: true,
payload: result,
}, fetchImpl);
return { ok: true, payload: result };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await submitCommandJobResult(config, jobId, {
ok: false,
error: message,
}, fetchImpl);
return { ok: false, error: message };
}
}
export function connectBroker( export function connectBroker(
config, config,
{ {
fetchImpl = fetch, fetchImpl = fetch,
wsFactory = (url) => new WebSocket(url), wsFactory = (url) => new WebSocket(url),
createShellBridgeImpl = createShellBridge,
onOpen = null, onOpen = null,
onClose = null, onClose = null,
onError = null, onError = null,
} = {} } = {}
) { ) {
const shell = createShellBridge((message) => { const shell = createShellBridgeImpl((message) => {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message)); socket.send(JSON.stringify(message));
} }
@@ -352,9 +493,13 @@ export function connectBroker(
const message = JSON.parse(raw.toString()); const message = JSON.parse(raw.toString());
try { try {
if (message.type === "COMMAND") { if (message.type === "COMMAND") {
const normalizedPayload =
message.payload && typeof message.payload === "object" && message.payload.payload
? message.payload.payload
: (message.payload || {});
const payload = await handleAgentCommand({ const payload = await handleAgentCommand({
commandType: message.commandType, commandType: message.commandType,
payload: message.payload || {}, payload: normalizedPayload,
}, { fetchImpl }); }, { fetchImpl });
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: "COMMAND_RESULT", type: "COMMAND_RESULT",
@@ -366,19 +511,23 @@ export function connectBroker(
} }
if (message.type === "OPEN_ROOT_SHELL") { if (message.type === "OPEN_ROOT_SHELL") {
shell.open(message.payload || {}); await shell.open(message.payload || {});
} else if (message.type === "SHELL_INPUT") { } else if (message.type === "SHELL_INPUT") {
shell.input(message.payload || {}); shell.input(message.payload || {});
} else if (message.type === "RESIZE_ROOT_SHELL") {
shell.resize(message.payload || {});
} else if (message.type === "CLOSE_ROOT_SHELL") { } else if (message.type === "CLOSE_ROOT_SHELL") {
shell.close(message.payload || {}); shell.close(message.payload || {});
} }
} catch (error) { } catch (error) {
socket.send(JSON.stringify({ if (message.type === "COMMAND") {
type: "COMMAND_RESULT", socket.send(JSON.stringify({
commandId: message.commandId, type: "COMMAND_RESULT",
ok: false, commandId: message.commandId,
error: error instanceof Error ? error.message : String(error), ok: false,
})); error: error instanceof Error ? error.message : String(error),
}));
}
} }
}); });
@@ -394,6 +543,8 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
config = await claimIfNeeded(config, configPath, fetchImpl); config = await claimIfNeeded(config, configPath, fetchImpl);
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000; const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
const reconnectDelayMs = Number(config.reconnectDelayMs || DEFAULT_RECONNECT_DELAY_MS); const reconnectDelayMs = Number(config.reconnectDelayMs || DEFAULT_RECONNECT_DELAY_MS);
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
let brokerConnected = false; let brokerConnected = false;
let socket = null; let socket = null;
@@ -417,6 +568,29 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
}, reconnectDelayMs); }, reconnectDelayMs);
}; };
const runCommandPollLoop = async () => {
while (!stopped) {
try {
const command = await pollCommandJob(config, fetchImpl, commandPollTimeoutSeconds);
if (stopped) {
break;
}
if (!command) {
continue;
}
await processPolledCommand(config, command, fetchImpl);
} catch {
if (stopped) {
break;
}
await new Promise((resolve) => setTimeout(resolve, commandPollRetryDelayMs));
}
}
};
const openBrokerConnection = () => { const openBrokerConnection = () => {
if (stopped) { if (stopped) {
return; return;
@@ -442,6 +616,7 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
await sendBrokerAwareHeartbeat(); await sendBrokerAwareHeartbeat();
openBrokerConnection(); openBrokerConnection();
const commandPollPromise = runCommandPollLoop();
const timer = setInterval(() => { const timer = setInterval(() => {
sendBrokerAwareHeartbeat().catch(() => {}); sendBrokerAwareHeartbeat().catch(() => {});
@@ -458,6 +633,7 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
}; };
return { return {
commandPollPromise,
get socket() { get socket() {
return socket; return socket;
}, },
+1
View File
@@ -3,6 +3,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
} }
+17
View File
@@ -6,9 +6,26 @@
"": { "": {
"name": "truckwash-edge-agent", "name": "truckwash-edge-agent",
"dependencies": { "dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
}, },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+1
View File
@@ -6,6 +6,7 @@
"test": "node --test" "test": "node --test"
}, },
"dependencies": { "dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
} }
+163 -27
View File
@@ -9,8 +9,8 @@ import {
claimIfNeeded, claimIfNeeded,
createShellBridge, createShellBridge,
getRelayStatus, getRelayStatus,
startAgent,
setRelayState, setRelayState,
startAgent,
} from "../dist/agent.mjs"; } from "../dist/agent.mjs";
test("claimIfNeeded persists claimed gateway credentials", async () => { test("claimIfNeeded persists claimed gateway credentials", async () => {
@@ -81,27 +81,90 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
assert.equal(switched.on, false); assert.equal(switched.on, false);
}); });
test("shell bridge streams child process output", async () => { test("shell bridge proxies PTY output, input, resize, and close events", async () => {
const messages = []; const messages = [];
const shell = createShellBridge((message) => messages.push(message)); const createdPtys = [];
shell.open({ const shell = createShellBridge(
(message) => messages.push(message),
{
createPtyProcess: async (options) => {
const listeners = {
data: null,
exit: null,
};
const pty = {
options,
writes: [],
resizes: [],
killed: false,
onData(callback) {
listeners.data = callback;
return {
dispose() {
listeners.data = null;
},
};
},
onExit(callback) {
listeners.exit = callback;
return {
dispose() {
listeners.exit = null;
},
};
},
write(data) {
this.writes.push(data);
},
resize(cols, rows) {
this.resizes.push({ cols, rows });
},
kill() {
this.killed = true;
listeners.exit?.({ exitCode: 0 });
},
emitData(data) {
listeners.data?.(data);
},
};
createdPtys.push(pty);
return pty;
},
}
);
await shell.open({
sessionId: "test-shell", sessionId: "test-shell",
shellCommand: process.execPath, shellCommand: "/bin/bash",
shellArgs: ["-e", "process.stdin.on('data', (d) => process.stdout.write(d))"], shellArgs: ["-l"],
cols: 90,
rows: 24,
cwd: "/tmp",
}); });
await new Promise((resolve) => setTimeout(resolve, 50)); createdPtys[0].emitData("root@pi:~# ");
shell.input({ sessionId: "test-shell", data: "hello\n" }); shell.input({ sessionId: "test-shell", data: "ls\r" });
await new Promise((resolve) => setTimeout(resolve, 50)); shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 });
shell.close({ sessionId: "test-shell" }); shell.close({ sessionId: "test-shell" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.ok(messages.some((message) => message.type === "SHELL_OPENED")); assert.equal(createdPtys.length, 1);
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("hello"))); assert.equal(createdPtys[0].options.command, "/bin/bash");
assert.deepEqual(createdPtys[0].options.args, ["-l"]);
assert.equal(createdPtys[0].options.cols, 90);
assert.equal(createdPtys[0].options.rows, 24);
assert.equal(createdPtys[0].options.cwd, "/tmp");
assert.deepEqual(createdPtys[0].writes, ["ls\r"]);
assert.deepEqual(createdPtys[0].resizes, [{ cols: 120, rows: 40 }]);
assert.equal(createdPtys[0].killed, true);
assert.ok(messages.some((message) => message.type === "SHELL_OPENED" && message.sessionId === "test-shell"));
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("root@pi")));
assert.ok(messages.some((message) => message.type === "SHELL_EXIT" && message.code === 0));
}); });
test("startAgent retries the broker tunnel and reports degraded heartbeats while disconnected", async () => { test("startAgent reports broker state via metadata and executes polled commands", async () => {
class FakeSocket extends EventEmitter { class FakeSocket extends EventEmitter {
constructor(url) { constructor(url) {
super(); super();
@@ -129,21 +192,85 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while
agentToken: "agent-token", agentToken: "agent-token",
heartbeatIntervalSeconds: 60, heartbeatIntervalSeconds: 60,
reconnectDelayMs: 10, reconnectDelayMs: 10,
commandPollTimeoutSeconds: 0,
commandPollRetryDelayMs: 5,
})); }));
const heartbeats = []; const heartbeats = [];
const fakeFetch = async (url, options = {}) => { const resultPosts = [];
heartbeats.push({ let polledCommandDelivered = false;
url,
body: JSON.parse(options.body ?? "{}"),
});
return { const fakeFetch = async (url, options = {}) => {
ok: true, const body = options.body ? JSON.parse(options.body) : {};
async json() {
return { data: { ok: true } }; if (String(url).endsWith("/heartbeat")) {
}, heartbeats.push({
}; url,
body,
});
return {
ok: true,
async json() {
return { data: { ok: true } };
},
};
}
if (String(url).endsWith("/commands/poll")) {
if (!polledCommandDelivered) {
polledCommandDelivered = true;
return {
ok: true,
async json() {
return {
data: {
id: 99,
commandType: "DISCOVER_SHELLY",
payload: {
candidateIps: ["10.1.0.31"],
},
},
};
},
};
}
await new Promise((resolve) => setTimeout(resolve, 5));
return {
ok: true,
async json() {
return { data: null };
},
};
}
if (String(url).endsWith("/commands/99/result")) {
resultPosts.push({
url,
body,
});
return {
ok: true,
async json() {
return { data: { acknowledged: true } };
},
};
}
if (String(url) === "http://10.1.0.31/shelly") {
return {
ok: true,
async json() {
return {
mac: "AA:BB:CC:DD:EE:FF",
model: "Shelly Plus 1PM",
num_switches: 1,
};
},
};
}
throw new Error(`Unexpected URL: ${url}`);
}; };
const sockets = []; const sockets = [];
@@ -160,19 +287,28 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while
}); });
assert.equal(heartbeats[0].body.status, "DEGRADED"); assert.equal(heartbeats[0].body.status, "DEGRADED");
assert.equal(heartbeats[0].body.discovery_status, "BROKER_DISCONNECTED"); assert.equal(heartbeats[0].body.metadata.broker_connected, false);
assert.equal("discovery_status" in heartbeats[0].body, false);
assert.equal(sockets.length, 1); assert.equal(sockets.length, 1);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(resultPosts.length, 1);
assert.equal(resultPosts[0].body.ok, true);
assert.equal(Array.isArray(resultPosts[0].body.payload.inventory), true);
assert.equal(resultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF");
sockets[0].readyState = 1; sockets[0].readyState = 1;
sockets[0].emit("open"); sockets[0].emit("open");
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.discovery_status === "READY")); assert.ok(heartbeats.some((heartbeat) => heartbeat.body.metadata.broker_connected === true));
assert.ok(heartbeats.every((heartbeat) => !("discovery_status" in heartbeat.body)));
sockets[0].emit("close"); sockets[0].emit("close");
await new Promise((resolve) => setTimeout(resolve, 25)); await new Promise((resolve) => setTimeout(resolve, 25));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.discovery_status === "BROKER_DISCONNECTED")); assert.ok(heartbeats.some((heartbeat) => heartbeat.body.status === "DEGRADED" && heartbeat.body.metadata.broker_connected === false));
assert.equal(sockets.length, 2); assert.equal(sockets.length, 2);
agent.stop(); agent.stop();
+10
View File
@@ -202,6 +202,16 @@ export function createBrokerServer(options = {}) {
}, },
})); }));
} }
if (message.type === "resize") {
agent.send(JSON.stringify({
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
}));
}
if (message.type === "close") { if (message.type === "close") {
agent.send(JSON.stringify({ agent.send(JSON.stringify({
type: "CLOSE_ROOT_SHELL", type: "CLOSE_ROOT_SHELL",
+46
View File
@@ -97,3 +97,49 @@ test("broker bridges browser shell sessions through the connected agent", async
agent.terminate(); agent.terminate();
await broker.close(); await broker.close();
}); });
test("broker forwards browser shell input, resize, and close events to the agent", async () => {
const broker = createBrokerServer({
authMode: "stub",
validateShellSession: async () => ({ id: "shell-2", gateway_id: "701", reason: "diagnostic" }),
});
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
const agentMessages = collectMessages(agent);
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
await new Promise((resolve) => browser.once("open", resolve));
await waitForMessage(agent);
browser.send(JSON.stringify({ type: "input", data: "ls\r" }));
browser.send(JSON.stringify({ type: "resize", cols: 140, rows: 44 }));
browser.send(JSON.stringify({ type: "close" }));
await new Promise((resolve) => setTimeout(resolve, 50));
assert.ok(agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-2"));
assert.ok(
agentMessages.some(
(message) =>
message.type === "SHELL_INPUT" &&
message.payload.sessionId === "shell-2" &&
message.payload.data === "ls\r"
)
);
assert.ok(
agentMessages.some(
(message) =>
message.type === "RESIZE_ROOT_SHELL" &&
message.payload.sessionId === "shell-2" &&
message.payload.cols === 140 &&
message.payload.rows === 44
)
);
assert.ok(agentMessages.some((message) => message.type === "CLOSE_ROOT_SHELL" && message.payload.sessionId === "shell-2"));
browser.terminate();
agent.terminate();
await broker.close();
});
@@ -363,7 +363,8 @@ class economic_v2_distribution_service
$department_id = (int)$order['department_id']; $department_id = (int)$order['department_id'];
$created_at = (string)$order['created_at']; $created_at = (string)$order['created_at'];
if (!$this->isDepartmentEligible($department_id)) { $order_is_included = $this->isOrderEligible($order);
if (!$order_is_included) {
continue; continue;
} }
@@ -398,7 +399,7 @@ class economic_v2_distribution_service
]; ];
} }
$customers[$customer_number]['transactions'][] = $this->buildTransactionObject($order_id, $created_at, $department_id, $order_discount_total); $customers[$customer_number]['transactions'][] = $this->buildTransactionObject($order_id, $created_at, $department_id, $order_discount_total, $order_is_included);
$customers[$customer_number]['meta']['customer_prices']['discount_total'] += $order_discount_total; $customers[$customer_number]['meta']['customer_prices']['discount_total'] += $order_discount_total;
if (!isset($customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id])) { if (!isset($customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id])) {
$customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] = 0.0; $customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] = 0.0;
@@ -1044,7 +1045,7 @@ class economic_v2_distribution_service
if (!$this->isSystemOrderCandidate($order, self::FIXED_PRICING_SYSTEM_ORDER_REFERENCE)) { if (!$this->isSystemOrderCandidate($order, self::FIXED_PRICING_SYSTEM_ORDER_REFERENCE)) {
continue; continue;
} }
} elseif (!$this->isDepartmentEligible($department_id)) { } elseif (!$this->isOrderEligible($order)) {
continue; continue;
} }
@@ -1087,7 +1088,13 @@ class economic_v2_distribution_service
$groups[$group_key]['order_ids'][] = $order_id; $groups[$group_key]['order_ids'][] = $order_id;
if (!isset($customer_transactions[$customer_number][$order_id])) { if (!isset($customer_transactions[$customer_number][$order_id])) {
$customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id); $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject(
$order_id,
$created_at,
$department_id,
null,
$this->isOrderEligible($order)
);
} }
} }
@@ -1115,7 +1122,7 @@ class economic_v2_distribution_service
if (!$this->isSystemOrderCandidate($order, self::WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE)) { if (!$this->isSystemOrderCandidate($order, self::WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE)) {
continue; continue;
} }
} elseif (!$this->isDepartmentEligible($department_id)) { } elseif (!$this->isOrderEligible($order)) {
continue; continue;
} }
@@ -1179,7 +1186,13 @@ class economic_v2_distribution_service
} }
if ($matched_order && !isset($customer_transactions[$customer_number][$order_id])) { if ($matched_order && !isset($customer_transactions[$customer_number][$order_id])) {
$customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id); $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject(
$order_id,
$created_at,
$department_id,
null,
$this->isOrderEligible($order)
);
} }
} }
@@ -1208,7 +1221,7 @@ class economic_v2_distribution_service
global $db; global $db;
$from = $db->escape_string($from_ts); $from = $db->escape_string($from_ts);
$to = $db->escape_string($to_ts); $to = $db->escape_string($to_ts);
$sql = "SELECT id, customer_id, department_id, created_at, reg_1, reference $sql = "SELECT id, customer_id, department_id, created_at, include_in_invoice, reg_1, reference
FROM orders FROM orders
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND created_at >= '$from' AND created_at >= '$from'
@@ -1540,7 +1553,7 @@ class economic_v2_distribution_service
]; ];
} }
protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null): array protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array
{ {
$order = (new orders_o())->select($order_id); $order = (new orders_o())->select($order_id);
return [ return [
@@ -1549,10 +1562,25 @@ class economic_v2_distribution_service
'amount' => round((float)($amount ?? (float)$order->getNetAmount()), 5), 'amount' => round((float)($amount ?? (float)$order->getNetAmount()), 5),
'booked' => $order->isBooked(true), 'booked' => $order->isBooked(true),
'department_id' => $department_id, 'department_id' => $department_id,
'excluded' => !$this->isDepartmentEligible($department_id), 'excluded' => !($included ?? $this->isDepartmentEligible($department_id)),
]; ];
} }
protected function isOrderEligible(array $order): bool
{
$department_id = (int)($order['department_id'] ?? 0);
if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) {
return false;
}
$override = orders_o::normalizeNullableBooleanValue($order['include_in_invoice'] ?? null);
if ($override !== null) {
return $override;
}
return $this->isDepartmentEligible($department_id);
}
private function getCustomerName(int $customer_number): string private function getCustomerName(int $customer_number): string
{ {
if (!isset($this->customer_name_cache[$customer_number])) { if (!isset($this->customer_name_cache[$customer_number])) {
@@ -4,6 +4,32 @@ namespace classes;
use Exception; use Exception;
class edge_broker_transport_exception extends Exception
{
public function __construct(string $message, private readonly int $curlErrno = 0, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function curlErrno(): int
{
return $this->curlErrno;
}
}
class edge_broker_http_exception extends Exception
{
public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function statusCode(): int
{
return $this->statusCode;
}
}
class edge_broker_client class edge_broker_client
{ {
private const DEFAULT_BROKER_URL = 'http://edge-broker:4300'; private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';
@@ -99,11 +125,12 @@ class edge_broker_client
$rawResponse = curl_exec($ch); $rawResponse = curl_exec($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch); $curlError = curl_error($ch);
curl_close($ch); curl_close($ch);
if ($rawResponse === false) { if ($rawResponse === false) {
throw new Exception('Edge broker request failed: ' . $curlError); throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno);
} }
$decoded = json_decode((string)$rawResponse, true); $decoded = json_decode((string)$rawResponse, true);
@@ -111,7 +138,7 @@ class edge_broker_client
$message = is_array($decoded) $message = is_array($decoded)
? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed') ? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')
: 'Edge broker request failed'; : 'Edge broker request failed';
throw new Exception($message); throw new edge_broker_http_exception($message, $statusCode);
} }
return $decoded; return $decoded;
@@ -28,6 +28,10 @@ class edge_gateway_manager
public const SHELL_SESSION_TTL_SECONDS = 900; public const SHELL_SESSION_TTL_SECONDS = 900;
public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60; public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60;
public const HEARTBEAT_OFFLINE_AFTER_SECONDS = 300; public const HEARTBEAT_OFFLINE_AFTER_SECONDS = 300;
public const COMMAND_WAIT_TIMEOUT_SECONDS = 10;
public const COMMAND_POLL_TIMEOUT_SECONDS = 20;
public const COMMAND_POLL_INTERVAL_MICROSECONDS = 250000;
public const COMMAND_DISPATCH_STALE_AFTER_SECONDS = 30;
public function __construct(private readonly ?edge_broker_client $brokerClient = null) public function __construct(private readonly ?edge_broker_client $brokerClient = null)
{ {
@@ -168,7 +172,6 @@ class edge_gateway_manager
$gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value()); $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value());
$gateway->last_heartbeat_at->set($this->now()); $gateway->last_heartbeat_at->set($this->now());
$gateway->last_seen_ip->set($this->remoteIp()); $gateway->last_seen_ip->set($this->remoteIp());
$gateway->discovery_status->set((string)($payload['discovery_status'] ?? $gateway->discovery_status->value()));
$gateway->metadata_json->set((array)($payload['metadata'] ?? $gateway->metadata_json->value() ?? [])); $gateway->metadata_json->set((array)($payload['metadata'] ?? $gateway->metadata_json->value() ?? []));
if (isset($payload['inventory']) && is_array($payload['inventory'])) { if (isset($payload['inventory']) && is_array($payload['inventory'])) {
@@ -324,15 +327,9 @@ class edge_gateway_manager
*/ */
public function queueDiscovery(int $gatewayId, ?int $userId = null): array public function queueDiscovery(int $gatewayId, ?int $userId = null): array
{ {
$gateway = $this->requireGateway($gatewayId); $gateway = $this->requireDispatchableGateway($gatewayId);
$job = $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId); $gateway->discovery_status->set('PENDING');
$response = $this->dispatchCommandJob($job, $gateway, [ $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId);
'requestedBy' => $userId,
]);
if (isset($response['inventory']) && is_array($response['inventory'])) {
$this->syncDeviceInventory($gatewayId, $response['inventory']);
}
return $this->getGateway($gatewayId); return $this->getGateway($gatewayId);
} }
@@ -342,10 +339,13 @@ class edge_gateway_manager
*/ */
public function queueUpdate(int $gatewayId, string $targetVersion, string $releaseChannel, ?int $userId = null): array public function queueUpdate(int $gatewayId, string $targetVersion, string $releaseChannel, ?int $userId = null): array
{ {
$gateway = $this->requireGateway($gatewayId); $gateway = $this->requireDispatchableGateway($gatewayId);
$gateway->target_version->set($targetVersion);
$jobObject = new edge_gateway_update_jobs_o(); $jobObject = new edge_gateway_update_jobs_o();
$jobId = $jobObject->add_object([ $jobId = $jobObject->add_object([
'gateway_id' => $gatewayId, 'gateway_id' => $gatewayId,
'command_job_id' => null,
'target_version' => $targetVersion, 'target_version' => $targetVersion,
'release_channel' => $releaseChannel, 'release_channel' => $releaseChannel,
'status' => 'PENDING', 'status' => 'PENDING',
@@ -359,22 +359,7 @@ class edge_gateway_manager
'targetVersion' => $targetVersion, 'targetVersion' => $targetVersion,
'releaseChannel' => $releaseChannel, 'releaseChannel' => $releaseChannel,
], $userId); ], $userId);
$jobObject->command_job_id->set((int)$command->id);
try {
$response = $this->dispatchCommandJob($command, $gateway, [
'targetVersion' => $targetVersion,
'releaseChannel' => $releaseChannel,
]);
$jobObject->status->set('COMPLETED');
$jobObject->started_at->set($this->now());
$jobObject->completed_at->set($this->now());
$jobObject->result_json->set($response);
} catch (\Throwable $throwable) {
$jobObject->status->set('FAILED');
$jobObject->completed_at->set($this->now());
$jobObject->result_json->set(['error' => $throwable->getMessage()]);
throw $throwable;
}
$this->writeAudit( $this->writeAudit(
$gatewayId, $gatewayId,
@@ -387,6 +372,71 @@ class edge_gateway_manager
return $jobObject->asArray(); return $jobObject->asArray();
} }
/**
* @throws Exception
*/
public function pollCommand(int $gatewayId, string $plainToken, int $waitSeconds = self::COMMAND_POLL_TIMEOUT_SECONDS): ?array
{
$gateway = $this->authenticateGateway($gatewayId, $plainToken);
$deadline = microtime(true) + max(0, $waitSeconds);
do {
$job = $this->claimNextCommandJob($gateway);
if ($job !== null) {
return $this->formatAgentCommandJob($job, $gateway);
}
if (microtime(true) >= $deadline) {
break;
}
usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
} while (true);
return null;
}
/**
* @throws Exception
*/
public function submitCommandResult(
int $gatewayId,
int $jobId,
string $plainToken,
bool $ok,
array $payload = [],
?string $error = null
): array {
$gateway = $this->authenticateGateway($gatewayId, $plainToken);
$job = (new edge_gateway_command_jobs_o())->select($jobId);
if (!$job->exists()) {
throw new Exception('Edge gateway command job not found');
}
if ((int)$job->gateway_id->value() !== (int)$gateway->id) {
throw new Exception('Edge gateway command job does not belong to this gateway');
}
$status = (string)$job->status->value();
if (in_array($status, ['COMPLETED', 'FAILED'], true)) {
return [
'acknowledged' => true,
'job' => $job->asArray(),
];
}
$errorMessage = $ok ? null : trim((string)$error);
if (!$ok && $errorMessage === '') {
$errorMessage = 'Edge gateway command failed';
}
$this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway);
return [
'acknowledged' => true,
'job' => $job->asArray(),
];
}
/** /**
* @throws Exception * @throws Exception
*/ */
@@ -502,7 +552,7 @@ class edge_gateway_manager
public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array
{ {
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
$gateway = $this->requireGateway((int)$binding['gateway_id']); $gateway = $this->requireDispatchableGateway((int)$binding['gateway_id']);
$job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', [ $job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', [
'relayId' => $logicalRelayId, 'relayId' => $logicalRelayId,
'deviceId' => $binding['device_id'], 'deviceId' => $binding['device_id'],
@@ -510,12 +560,9 @@ class edge_gateway_manager
'channel' => (int)$binding['channel'], 'channel' => (int)$binding['channel'],
], null); ], null);
return $this->dispatchCommandJob($job, $gateway, [ $this->tryImmediateBrokerDispatch($job, $gateway);
'relayId' => $logicalRelayId,
'deviceId' => $binding['device_id'], return $this->waitForCommandResult((int)$job->id);
'localIp' => $binding['local_ip'],
'channel' => (int)$binding['channel'],
]);
} }
/** /**
@@ -524,7 +571,7 @@ class edge_gateway_manager
public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array
{ {
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
$gateway = $this->requireGateway((int)$binding['gateway_id']); $gateway = $this->requireDispatchableGateway((int)$binding['gateway_id']);
$job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', [ $job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', [
'relayId' => $logicalRelayId, 'relayId' => $logicalRelayId,
'deviceId' => $binding['device_id'], 'deviceId' => $binding['device_id'],
@@ -533,13 +580,9 @@ class edge_gateway_manager
'on' => $on, 'on' => $on,
], null); ], null);
return $this->dispatchCommandJob($job, $gateway, [ $this->tryImmediateBrokerDispatch($job, $gateway);
'relayId' => $logicalRelayId,
'deviceId' => $binding['device_id'], return $this->waitForCommandResult((int)$job->id);
'localIp' => $binding['local_ip'],
'channel' => (int)$binding['channel'],
'on' => $on,
]);
} }
/** /**
@@ -592,7 +635,7 @@ INSTALL_DIR=/opt/truckwash-edge-agent
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update apt-get update
apt-get install -y curl ca-certificates nodejs npm apt-get install -y curl ca-certificates nodejs npm python3 make g++
curl -fsSL "__PACKAGE_URL__" -o "$INSTALL_DIR/package.json" curl -fsSL "__PACKAGE_URL__" -o "$INSTALL_DIR/package.json"
curl -fsSL "__AGENT_URL__" -o "$INSTALL_DIR/agent.mjs" curl -fsSL "__AGENT_URL__" -o "$INSTALL_DIR/agent.mjs"
cat > "$INSTALL_DIR/config.json" <<'EOF_JSON' cat > "$INSTALL_DIR/config.json" <<'EOF_JSON'
@@ -654,7 +697,7 @@ BASH;
{ {
$configured = trim((string)(getenv('EDGE_BROKER_PUBLIC_URL') ?: '')); $configured = trim((string)(getenv('EDGE_BROKER_PUBLIC_URL') ?: ''));
if ($configured !== '') { if ($configured !== '') {
return $configured; return $this->normalizeBrokerPublicUrl($configured);
} }
$apiBaseUrl = $this->getApiBaseUrl(); $apiBaseUrl = $this->getApiBaseUrl();
@@ -665,7 +708,7 @@ BASH;
$scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'https' : 'http'; $scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'https' : 'http';
$port = getenv('EDGE_BROKER_PUBLIC_PORT') ?: '4300'; $port = getenv('EDGE_BROKER_PUBLIC_PORT') ?: '4300';
return $scheme . '://' . $parsed['host'] . ':' . $port; return $this->normalizeBrokerPublicUrl($scheme . '://' . $parsed['host'] . ':' . $port);
} }
public function getApiBaseUrl(): string public function getApiBaseUrl(): string
@@ -740,6 +783,76 @@ BASH;
return null; return null;
} }
private function normalizeBrokerPublicUrl(string $url): string
{
$parsed = parse_url($url);
if (!is_array($parsed) || !isset($parsed['host'])) {
return trim($url);
}
$host = (string)$parsed['host'];
$scheme = strtolower((string)($parsed['scheme'] ?? ''));
if ($scheme === '' || ($scheme === 'http' && $this->shouldUseSecureBrokerScheme($host))) {
$scheme = $this->shouldUseSecureBrokerScheme($host) ? 'https' : 'http';
}
$normalized = $scheme . '://' . $host;
if (isset($parsed['port'])) {
$normalized .= ':' . $parsed['port'];
}
if (isset($parsed['path'])) {
$normalized .= $parsed['path'];
}
if (isset($parsed['query'])) {
$normalized .= '?' . $parsed['query'];
}
if (isset($parsed['fragment'])) {
$normalized .= '#' . $parsed['fragment'];
}
return $normalized;
}
private function shouldUseSecureBrokerScheme(string $host): bool
{
$normalized = strtolower(trim($host, '[]'));
if ($normalized === '' || $normalized === 'localhost' || $normalized === 'edge-broker') {
return false;
}
if (str_ends_with($normalized, '.localhost')
|| str_ends_with($normalized, '.local')
|| str_ends_with($normalized, '.lan')
|| str_ends_with($normalized, '.internal')
|| str_ends_with($normalized, '.home.arpa')) {
return false;
}
if (filter_var($normalized, FILTER_VALIDATE_IP) !== false) {
return filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
return str_contains($normalized, '.');
}
/**
* @throws Exception
*/
private function requireDispatchableGateway(int $gatewayId): edge_gateways_o
{
$gateway = $this->requireGateway($gatewayId);
$effectiveStatus = self::resolveGatewayStatus(
$gateway->status->value() === null ? null : (string)$gateway->status->value(),
$gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
);
if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) {
throw new Exception('Gateway agent is offline');
}
return $gateway;
}
/** /**
* @throws Exception * @throws Exception
*/ */
@@ -856,32 +969,282 @@ BASH;
/** /**
* @throws Exception * @throws Exception
*/ */
private function dispatchCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway, array $payload): array private function tryImmediateBrokerDispatch(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): void
{ {
$job->status->set('DISPATCHING'); $this->markCommandJobDispatching($job);
$response = $this->broker()->dispatchCommand( try {
(int)$gateway->id, $response = $this->broker()->dispatchCommand(
(string)$job->command_type->value(), (int)$gateway->id,
[ (string)$job->command_type->value(),
'jobId' => (int)$job->id, $this->buildCommandExecutionPayload($job, $gateway)
'gatewayId' => (int)$gateway->id, );
'departmentId' => (int)$gateway->department_id->value(),
'payload' => $payload, $ok = (bool)($response['ok'] ?? false);
] $payload = (array)($response['payload'] ?? []);
); $errorMessage = $ok ? null : trim((string)($response['error'] ?? 'Edge broker command failed'));
$this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway);
if (!$ok) {
throw new Exception($errorMessage ?: 'Edge broker command failed');
}
} catch (\Throwable $throwable) {
if ($this->shouldFallbackToQueuedDelivery($throwable)) {
$this->releaseCommandJobToQueue($job);
return;
}
$this->finalizeCommandJob($job, false, [], $throwable->getMessage(), $gateway);
throw $throwable;
}
}
/**
* @throws Exception
*/
private function waitForCommandResult(int $jobId, int $timeoutSeconds = self::COMMAND_WAIT_TIMEOUT_SECONDS): array
{
$deadline = microtime(true) + max(0, $timeoutSeconds);
do {
$job = (new edge_gateway_command_jobs_o())->select($jobId);
if (!$job->exists()) {
throw new Exception('Edge gateway command job not found');
}
$status = (string)$job->status->value();
if ($status === 'COMPLETED') {
$response = (array)($job->response_json->value() ?? []);
return (array)($response['payload'] ?? []);
}
if ($status === 'FAILED') {
$errorMessage = trim((string)($job->error_message->value() ?? ''));
throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed');
}
if (microtime(true) >= $deadline) {
break;
}
usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
} while (true);
throw new Exception('Edge gateway command timed out');
}
private function claimNextCommandJob(edge_gateways_o $gateway): ?edge_gateway_command_jobs_o
{
$pdo = db::getPDO();
$pdo->beginTransaction();
try {
$statement = $pdo->prepare(
'SELECT id
FROM edge_gateway_command_jobs
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND (
status = :pending_status_match
OR (
status = :dispatching_status_match
AND COALESCE(updated_at, created_at, requested_at) <= :stale_before
)
)
ORDER BY CASE WHEN status = :pending_status_order THEN 0 ELSE 1 END, requested_at ASC, id ASC
LIMIT 1
FOR UPDATE'
);
$statement->execute([
':gateway_id' => (int)$gateway->id,
':pending_status_match' => 'PENDING',
':dispatching_status_match' => 'DISPATCHING',
':pending_status_order' => 'PENDING',
':stale_before' => $this->formatDateTime(time() - self::COMMAND_DISPATCH_STALE_AFTER_SECONDS),
]);
$row = $statement->fetch();
if (!is_array($row) || !isset($row['id'])) {
$pdo->commit();
return null;
}
$update = $pdo->prepare(
'UPDATE edge_gateway_command_jobs
SET status = :status,
response_json = :response_json,
error_message = NULL,
completed_at = NULL
WHERE id = :id'
);
$update->execute([
':status' => 'DISPATCHING',
':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
':id' => (int)$row['id'],
]);
$pdo->commit();
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $throwable;
}
$job = (new edge_gateway_command_jobs_o())->select((int)$row['id']);
$this->markLinkedUpdateJobStarted((int)$job->id);
return $job;
}
private function formatAgentCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array
{
return [
'id' => (int)$job->id,
'gateway_id' => (int)$gateway->id,
'department_id' => (int)$gateway->department_id->value(),
'command_type' => (string)$job->command_type->value(),
'commandType' => (string)$job->command_type->value(),
'payload' => $this->buildCommandExecutionPayload($job, $gateway),
'requested_at' => (string)$job->requested_at->value(),
];
}
private function buildCommandExecutionPayload(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array
{
return array_merge([
'jobId' => (int)$job->id,
'gatewayId' => (int)$gateway->id,
'departmentId' => (int)$gateway->department_id->value(),
'correlationId' => (string)$job->correlation_id->value(),
], (array)($job->request_json->value() ?? []));
}
private function finalizeCommandJob(
edge_gateway_command_jobs_o $job,
bool $ok,
array $payload = [],
?string $errorMessage = null,
?edge_gateways_o $gateway = null
): void {
$gatewayObject = $gateway ?? $this->requireGateway((int)$job->gateway_id->value());
$response = [
'ok' => $ok,
'payload' => $payload,
];
if (!$ok && $errorMessage !== null && trim($errorMessage) !== '') {
$response['error'] = $errorMessage;
}
$job->response_json->set($response); $job->response_json->set($response);
$job->completed_at->set($this->now()); $job->completed_at->set($this->now());
$job->error_message->set($ok ? null : $errorMessage);
$ok = (bool)($response['ok'] ?? false);
$job->status->set($ok ? 'COMPLETED' : 'FAILED'); $job->status->set($ok ? 'COMPLETED' : 'FAILED');
if (!$ok) {
$job->error_message->set((string)($response['error'] ?? 'Edge broker command failed')); $this->applyCommandResult($gatewayObject, $job, $ok, $payload, $errorMessage);
throw new Exception((string)($response['error'] ?? 'Edge broker command failed')); }
private function applyCommandResult(
edge_gateways_o $gateway,
edge_gateway_command_jobs_o $job,
bool $ok,
array $payload,
?string $errorMessage
): void {
$commandType = (string)$job->command_type->value();
if ($commandType === 'DISCOVER_SHELLY') {
if ($ok) {
$inventory = isset($payload['inventory']) && is_array($payload['inventory']) ? $payload['inventory'] : [];
$this->syncDeviceInventory((int)$gateway->id, $inventory);
$gateway->discovery_status->set('READY');
} else {
$gateway->discovery_status->set('FAILED');
}
return;
} }
return (array)($response['payload'] ?? $response); if ($commandType === 'RUN_UPDATE') {
$this->finalizeLinkedUpdateJob((int)$job->id, $ok, $payload, $errorMessage);
}
}
private function releaseCommandJobToQueue(edge_gateway_command_jobs_o $job): void
{
$job->response_json->set([]);
$job->error_message->set(null);
$job->completed_at->set(null);
$job->status->set('PENDING');
}
private function markCommandJobDispatching(edge_gateway_command_jobs_o $job): void
{
$job->response_json->set([]);
$job->error_message->set(null);
$job->completed_at->set(null);
$job->status->set('DISPATCHING');
}
private function shouldFallbackToQueuedDelivery(\Throwable $throwable): bool
{
if ($throwable instanceof edge_broker_transport_exception) {
return true;
}
if ($throwable instanceof edge_broker_http_exception) {
return $throwable->statusCode() === 503;
}
$message = $throwable->getMessage();
return str_contains($message, 'Gateway agent is offline')
|| str_contains($message, 'Could not resolve host:')
|| str_contains($message, 'Failed to connect')
|| str_contains($message, 'Connection refused');
}
private function markLinkedUpdateJobStarted(int $commandJobId): void
{
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
if ($updateJob === null) {
return;
}
if ($updateJob->started_at->value() === null) {
$updateJob->started_at->set($this->now());
}
}
private function finalizeLinkedUpdateJob(int $commandJobId, bool $ok, array $payload, ?string $errorMessage): void
{
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
if ($updateJob === null) {
return;
}
if ($updateJob->started_at->value() === null) {
$updateJob->started_at->set($this->now());
}
$updateJob->status->set($ok ? 'COMPLETED' : 'FAILED');
$updateJob->completed_at->set($this->now());
$updateJob->result_json->set($ok
? $payload
: ['error' => $errorMessage ?: 'Edge gateway update failed']);
}
private function findLinkedUpdateJobByCommandId(int $commandJobId): ?edge_gateway_update_jobs_o
{
$rows = (new edge_gateway_update_jobs_o())->getFieldsWhere([
'command_job_id' => $commandJobId,
'deleted_at' => null,
], ['id']);
if ($rows === []) {
return null;
}
return (new edge_gateway_update_jobs_o())->select((int)$rows[0]['id']);
} }
/** /**
@@ -125,6 +125,7 @@ class edge_gateway_schema_bootstrap
"CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs ( "CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL, gateway_id INT NOT NULL,
command_job_id INT NULL,
target_version VARCHAR(64) NOT NULL, target_version VARCHAR(64) NOT NULL,
release_channel VARCHAR(32) NOT NULL DEFAULT 'stable', release_channel VARCHAR(32) NOT NULL DEFAULT 'stable',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING', status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
@@ -137,6 +138,7 @@ class edge_gateway_schema_bootstrap
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL, deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_edge_gateway_update_gateway (gateway_id), INDEX idx_edge_gateway_update_gateway (gateway_id),
INDEX idx_edge_gateway_update_command (command_job_id),
INDEX idx_edge_gateway_update_status (status) INDEX idx_edge_gateway_update_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -182,6 +184,38 @@ class edge_gateway_schema_bootstrap
$db->query($sql); $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::$initialized = true; self::$initialized = true;
} }
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'"
);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
} }
@@ -0,0 +1,66 @@
<?php
namespace classes;
use DateTimeImmutable;
use InvalidArgumentException;
class orders_input_normalizer
{
public static function normalizeCreatedAt(mixed $value): string
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (!is_string($value)) {
throw new InvalidArgumentException('created_at must be a string');
}
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException('created_at cannot be empty');
}
foreach (['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i'] as $format) {
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
if ($parsed instanceof DateTimeImmutable && $parsed->format($format) === $trimmed) {
return $parsed->format('Y-m-d H:i:s');
}
}
throw new InvalidArgumentException('created_at must be a valid datetime');
}
public static function normalizeIncludeInInvoice(mixed $value): ?bool
{
if ($value === null) {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
if ($value === 1) {
return true;
}
if ($value === 0) {
return false;
}
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
return match ($normalized) {
'', 'null', 'use_department' => null,
'1', 'true', 'include', 'included', 'yes' => true,
'0', 'false', 'exclude', 'excluded', 'no' => false,
default => throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude'),
};
}
throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude');
}
}
@@ -0,0 +1,32 @@
<?php
namespace classes;
/**
* Ensures additive schema for orders metadata.
*/
class orders_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"ALTER TABLE orders
ADD COLUMN IF NOT EXISTS include_in_invoice TINYINT(1) NULL DEFAULT NULL
AFTER created_at"
);
self::$initialized = true;
}
}
@@ -73,11 +73,52 @@ class economicCustomers extends economic_m
if ($customer_number === 0) { if ($customer_number === 0) {
return 0; return 0;
} }
// Get the customer products // The discount is global, but e-conomic resolves it through a product-specific
$products = $this->getCustomerProducts($customer_number, 1)->collection; // invoice-line template. For foreign-currency customers some templates can fail
$discount = $this->getCustomerProductDiscount($customer_number, $products[0]->product->productNumber); // if that product has no price in the customer currency, so try a few products
// Since the discount is global, we only need to get the discount for one product // before falling back to zero.
return $discount->discountPercentage ?? 0; $products = $this->getCustomerProducts($customer_number, 10);
foreach ($this->extractCustomerProductNumbers($products) as $product_number) {
try {
$discount = $this->getCustomerProductDiscount($customer_number, $product_number);
return (int)($discount->discountPercentage ?? 0);
} catch (\RuntimeException $exception) {
if (!$this->isMissingCurrencyPriceLookupError($exception)) {
throw $exception;
}
}
}
return 0;
}
/**
* @return int[]
*/
private function extractCustomerProductNumbers(object $products): array
{
if (!isset($products->collection) || !is_array($products->collection)) {
return [];
}
$product_numbers = [];
foreach ( $products->collection as $product ) {
$product_number = $product->product->productNumber ?? null;
if ($product_number === null || $product_number === '') {
continue;
}
$product_numbers[] = (int)$product_number;
}
return array_values(array_unique($product_numbers));
}
private function isMissingCurrencyPriceLookupError(\RuntimeException $exception): bool
{
$message = $exception->getMessage();
return str_contains($message, 'No price in currency')
&& str_contains($message, 'can be found for the product');
} }
public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object
@@ -12,6 +12,7 @@ class edge_gateway_update_jobs_o extends db
use db_object_t; use db_object_t;
public object_property $gateway_id; public object_property $gateway_id;
public object_property $command_job_id;
public object_property $target_version; public object_property $target_version;
public object_property $release_channel; public object_property $release_channel;
public object_property $status; public object_property $status;
@@ -33,6 +34,7 @@ class edge_gateway_update_jobs_o extends db
public function getObjectProperties(): void public function getObjectProperties(): void
{ {
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); $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->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->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false);
$this->status = new object_property($this->table, $this->id, 'status', 'string', false); $this->status = new object_property($this->table, $this->id, 'status', 'string', false);
@@ -57,6 +59,7 @@ class edge_gateway_update_jobs_o extends db
return [ return [
'id' => (int)$this->id, 'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(), '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(), 'target_version' => (string)$this->target_version->value(),
'release_channel' => (string)$this->release_channel->value(), 'release_channel' => (string)$this->release_channel->value(),
'status' => (string)$this->status->value(), 'status' => (string)$this->status->value(),
+44
View File
@@ -6,6 +6,7 @@ use attachments\helpers\attachment_content;
use classes\db; use classes\db;
use classes\email; use classes\email;
use classes\invoicing_period_utils; use classes\invoicing_period_utils;
use classes\orders_schema_bootstrap;
use classes\pdf_generator; use classes\pdf_generator;
use classes\motorapi; use classes\motorapi;
use classes\object_property; use classes\object_property;
@@ -31,6 +32,7 @@ class orders_o extends db
public object_property $reg_3; public object_property $reg_3;
public economic_module_orders $economic_module_orders; public economic_module_orders $economic_module_orders;
public object_property $created_at; public object_property $created_at;
public object_property $include_in_invoice;
public object_property $deleted_at; public object_property $deleted_at;
public object_property $completed_at; public object_property $completed_at;
@@ -53,6 +55,7 @@ class orders_o extends db
public function structure(): void public function structure(): void
{ {
orders_schema_bootstrap::ensureTables();
$this->setTable('orders'); $this->setTable('orders');
} }
@@ -86,6 +89,7 @@ class orders_o extends db
$this->reg_2 = new object_property($this->table, $this->id, 'reg_2', 'string', false); $this->reg_2 = new object_property($this->table, $this->id, 'reg_2', 'string', false);
$this->reg_3 = new object_property($this->table, $this->id, 'reg_3', 'string', false); $this->reg_3 = new object_property($this->table, $this->id, 'reg_3', 'string', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'timestamp', false); $this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'timestamp', false);
$this->economic_module_orders = (new economic_module_orders())->getByOrderId($this->id); $this->economic_module_orders = (new economic_module_orders())->getByOrderId($this->id);
$this->stripe_module_orders = (new stripe_module_orders_o())->select($this->id); $this->stripe_module_orders = (new stripe_module_orders_o())->select($this->id);
@@ -568,6 +572,8 @@ class orders_o extends db
'reg_3' => $this->reg_3->value(), 'reg_3' => $this->reg_3->value(),
'completed_at' => $this->completed_at->value(), 'completed_at' => $this->completed_at->value(),
'created_at' => $this->created_at->value(), 'created_at' => $this->created_at->value(),
'include_in_invoice' => $this->getIncludeInInvoiceOverride(),
'include_in_invoice_effective' => $this->isIncludedInInvoicing(),
'deleted_at' => $this->deleted_at->value(), 'deleted_at' => $this->deleted_at->value(),
'total_net_amount' => $this->temporary_net_amount ?: $this->getNetAmount(), 'total_net_amount' => $this->temporary_net_amount ?: $this->getNetAmount(),
'invoice_collection_id' => (int)$this->invoice_collection_id->value(), 'invoice_collection_id' => (int)$this->invoice_collection_id->value(),
@@ -1491,6 +1497,44 @@ class orders_o extends db
public function isIncludedInInvoicing(): bool public function isIncludedInInvoicing(): bool
{ {
self::requireSelected(); self::requireSelected();
$override = $this->getIncludeInInvoiceOverride();
if ($override !== null) {
return $override;
}
return $this->resolveDepartmentIncludedInInvoicing();
}
public static function normalizeNullableBooleanValue(mixed $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value === 1 ? true : ($value === 0 ? false : null);
}
$normalized = strtolower(trim((string)$value));
return match ($normalized) {
'1', 'true' => true,
'0', 'false' => false,
default => null,
};
}
public function getIncludeInInvoiceOverride(): ?bool
{
self::requireSelected();
return self::normalizeNullableBooleanValue($this->include_in_invoice->value());
}
protected function resolveDepartmentIncludedInInvoicing(): bool
{
return !(new departments_o())->select((int)$this->department_id->value())->isExcludedFromInvoicing(); return !(new departments_o())->select((int)$this->department_id->value())->isExcludedFromInvoicing();
} }
+47
View File
@@ -11059,6 +11059,35 @@ paths:
schema: schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintDeleteResponse' $ref: '#/components/schemas/DepartmentDailyReportComplaintDeleteResponse'
/departments/daily-reports/complaints/customers:
get:
tags:
- Departments
summary: Search selectable customers for daily report complaints
operationId: searchDailyReportComplaintCustomers
parameters:
- name: search
in: query
required: true
schema:
type: string
minLength: 2
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 20
default: 10
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResponse'
/departments/daily-reports/product-count: /departments/daily-reports/product-count:
get: get:
tags: tags:
@@ -17024,6 +17053,15 @@ components:
minLength: 1 minLength: 1
maxLength: 4000 maxLength: 4000
DepartmentDailyReportComplaintCustomerSearchResult:
type: object
properties:
customer_number:
type: integer
customer_name:
type: string
nullable: true
DepartmentDailyReportComplaint: DepartmentDailyReportComplaint:
type: object type: object
properties: properties:
@@ -17065,6 +17103,15 @@ components:
items: items:
$ref: '#/components/schemas/DepartmentDailyReportComplaint' $ref: '#/components/schemas/DepartmentDailyReportComplaint'
DepartmentDailyReportComplaintCustomerSearchResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
type: array
items:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResult'
DepartmentDailyReportComplaintDeleteResponse: DepartmentDailyReportComplaintDeleteResponse:
type: object type: object
properties: properties:
@@ -1327,7 +1327,7 @@ class InvoicingPeriodRoute
'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier
'booked' => $transaction->isBooked(true), 'booked' => $transaction->isBooked(true),
'department_id' => $departmentId, 'department_id' => $departmentId,
'excluded' => self::isDepartmentExcludedFromInvoicingCached($departmentId), 'excluded' => !$transaction->isIncludedInInvoicing(),
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
'queue_status' => null, 'queue_status' => null,
'queue_job_id' => null, 'queue_job_id' => null,
@@ -5,6 +5,7 @@ namespace routes;
use classes\authentication; use classes\authentication;
use classes\department_outside_hours_statistics_service; use classes\department_outside_hours_statistics_service;
use classes\workfeed; use classes\workfeed;
use customers\economicCustomers;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
use DateTimeZone; use DateTimeZone;
@@ -343,6 +344,119 @@ class departmentDailyReportsRoute
] ]
); );
$this->get('/departments/daily-reports/complaints/customers', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$has_create_permission = $this->hasPermission('create_department_daily_report_complaints');
$has_edit_permission = $this->hasPermission('edit_department_daily_report_complaints');
if (!$has_create_permission && !$has_edit_permission) {
$this->emitForbidden([
'create_department_daily_report_complaints',
'edit_department_daily_report_complaints',
]);
return;
}
self::requireParameters(['search']);
self::requireType(
self::getParameter('search'),
self::type_string()
);
$search = trim((string)self::getParameter('search'));
if (mb_strlen($search) < 2) {
$response->error('Search must be at least 2 characters', 400);
return;
}
$limit = 10;
if (
self::isParametersSet(['limit'])
&& self::getParameter('limit') !== null
&& trim((string)self::getParameter('limit')) !== ''
) {
self::requireType(
(int)self::getParameter('limit'),
self::type_int()
);
self::requireMinValue(
(int)self::getParameter('limit'),
1
);
self::requireMaxValue(
(int)self::getParameter('limit'),
20
);
$limit = (int)self::getParameter('limit');
}
try {
$result = $this->complaintCustomerSearchService()->listCustomers(1, $limit, $search, null);
$collection = is_array($result->collection ?? null) ? $result->collection : [];
} catch (\Throwable $throwable) {
$upstream_message = $this->sanitizeComplaintCustomerLookupUpstreamErrorMessage($throwable);
(new logs_o())->add(
'departments',
'global',
1,
$user->id,
'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS_FAILED',
'Failed to search complaint customers from e-conomic: ' . $upstream_message
);
$response->error([
'message' => 'Failed to fetch complaint customers from e-conomic',
'upstream_message' => $upstream_message,
], 502);
return;
}
$matches = array_values(array_filter(array_map(
static function (mixed $customer): ?array {
$customer_number = 0;
$customer_name = null;
if (is_object($customer)) {
$customer_number = (int)($customer->customerNumber ?? 0);
$name = trim((string)($customer->name ?? ''));
$customer_name = $name === '' ? null : $name;
} elseif (is_array($customer)) {
$customer_number = (int)($customer['customerNumber'] ?? 0);
$name = trim((string)($customer['name'] ?? ''));
$customer_name = $name === '' ? null : $name;
}
if ($customer_number <= 0) {
return null;
}
return [
'customer_number' => $customer_number,
'customer_name' => $customer_name,
];
},
$collection
)));
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS', 'Successfully searched complaint customers');
$response->success($matches);
},
[
'create_department_daily_report_complaints' => 'Search customers when creating department daily report customer complaints',
'edit_department_daily_report_complaints' => 'Search customers when editing department daily report customer complaints',
]
);
$this->get('/departments/daily-reports/complaints', function () { $this->get('/departments/daily-reports/complaints', function () {
global $response; global $response;
$this->requirePermission('list_department_daily_report_complaints'); $this->requirePermission('list_department_daily_report_complaints');
@@ -1901,11 +2015,31 @@ class departmentDailyReportsRoute
return new department_daily_report_complaints_o(); return new department_daily_report_complaints_o();
} }
protected function complaintCustomerSearchService(): economicCustomers
{
return new economicCustomers();
}
protected function outsideHoursStatisticsService(): department_outside_hours_statistics_service protected function outsideHoursStatisticsService(): department_outside_hours_statistics_service
{ {
return new department_outside_hours_statistics_service(); return new department_outside_hours_statistics_service();
} }
private function sanitizeComplaintCustomerLookupUpstreamErrorMessage(\Throwable $throwable): string
{
$message = trim($throwable->getMessage());
if ($message === '') {
return 'Unexpected e-conomic integration error.';
}
$message = preg_replace('/\s+/', ' ', $message);
if (!is_string($message)) {
return 'Unexpected e-conomic integration error.';
}
return substr($message, 0, 500);
}
private function departmentIdFromValue(mixed $department): int private function departmentIdFromValue(mixed $department): int
{ {
if (is_array($department)) { if (is_array($department)) {
@@ -185,6 +185,8 @@ class edgeGatewaysRoute
$this->get('/edge-agent/artifacts/package.json', fn() => $this->renderAgentArtifact('package.json', 'application/json; charset=utf-8')); $this->get('/edge-agent/artifacts/package.json', fn() => $this->renderAgentArtifact('package.json', 'application/json; charset=utf-8'));
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim()); $this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat()); $this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
$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/internal/agent/auth', fn() => $this->handleInternalAgentAuth()); $this->post('/edge-agent/internal/agent/auth', fn() => $this->handleInternalAgentAuth());
$this->post('/edge-agent/internal/shell/auth', fn() => $this->handleInternalShellAuth()); $this->post('/edge-agent/internal/shell/auth', fn() => $this->handleInternalShellAuth());
$this->post('/edge-agent/internal/shell-sessions/{id}/close', fn() => $this->handleInternalShellClose()); $this->post('/edge-agent/internal/shell-sessions/{id}/close', fn() => $this->handleInternalShellClose());
@@ -245,6 +247,45 @@ class edgeGatewaysRoute
$response->success((new edge_gateway_manager())->recordHeartbeat($gatewayId, $token, $payload)); $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));
}
private function handleAgentCommandResult(): void
{
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(
$gatewayId,
$jobId,
$token,
(bool)($payload['ok'] ?? false),
isset($payload['payload']) && is_array($payload['payload']) ? (array)$payload['payload'] : [],
isset($payload['error']) ? (string)$payload['error'] : null
));
}
private function handleInternalAgentAuth(): void private function handleInternalAgentAuth(): void
{ {
global /** @var response $response */ $response; global /** @var response $response */ $response;
+27 -2
View File
@@ -6,6 +6,7 @@ use attachments\helpers\attachment_content;
use classes\attachment_store; use classes\attachment_store;
use classes\attachments; use classes\attachments;
use classes\authentication; use classes\authentication;
use classes\orders_input_normalizer;
use classes\response; use classes\response;
use classes\stripe; use classes\stripe;
use JetBrains\PhpStorm\NoReturn; use JetBrains\PhpStorm\NoReturn;
@@ -141,6 +142,15 @@ class ordersRoute
$reg_1 = preg_replace('/\s+/', '', $reg_1); $reg_1 = preg_replace('/\s+/', '', $reg_1);
$reg_2 = preg_replace('/\s+/', '', $reg_2); $reg_2 = preg_replace('/\s+/', '', $reg_2);
$reg_3 = preg_replace('/\s+/', '', $reg_3); $reg_3 = preg_replace('/\s+/', '', $reg_3);
try {
$createdAt = orders_input_normalizer::normalizeCreatedAt($data['created_at'] ?? date('Y-m-d H:i:s'));
$includeInInvoice = array_key_exists('include_in_invoice', $data)
? orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice'])
: null;
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
$new_data = [ $new_data = [
'customer_id' => (int)$data['customer_id'], 'customer_id' => (int)$data['customer_id'],
'department_id' => (int)$data['department_id'], 'department_id' => (int)$data['department_id'],
@@ -153,7 +163,8 @@ class ordersRoute
...(!empty($data['lane']) ? ['lane' => (int)$data['lane']] : []), // Optional lane ...(!empty($data['lane']) ? ['lane' => (int)$data['lane']] : []), // Optional lane
...(!empty($data['wash_id']) ? ['wash_id' => (string)$data['wash_id']] : []), // Optional wash ID ...(!empty($data['wash_id']) ? ['wash_id' => (string)$data['wash_id']] : []), // Optional wash ID
...(!empty($data['booking_id']) ? ['booking_id' => (int)$data['booking_id']] : []), // Optional booking ID ...(!empty($data['booking_id']) ? ['booking_id' => (int)$data['booking_id']] : []), // Optional booking ID
'created_at' => (string)($data['created_at'] ?? date('Y-m-d H:i:s')), // Default to current time if not set 'created_at' => $createdAt, // Default to current time if not set
...(array_key_exists('include_in_invoice', $data) ? ['include_in_invoice' => $includeInInvoice] : []),
]; ];
// Create the order // Create the order
//$order = (new orders_o())->add((int)$data['customer_id'], $user->id, $data['reference'], $data['notes'], (int)$data['department_id'], (string)$reg_1, (string)$reg_2, (string)$reg_3); //$order = (new orders_o())->add((int)$data['customer_id'], $user->id, $data['reference'], $data['notes'], (int)$data['department_id'], (string)$reg_1, (string)$reg_2, (string)$reg_3);
@@ -1056,7 +1067,21 @@ class ordersRoute
} }
// Check if the created_at is set // Check if the created_at is set
if (isset($data['created_at'])) { if (isset($data['created_at'])) {
$order->created_at->set($data['created_at']); try {
$order->created_at->set(orders_input_normalizer::normalizeCreatedAt($data['created_at']));
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
}
// Check if include_in_invoice is set
if (array_key_exists('include_in_invoice', $data)) {
try {
$order->include_in_invoice->set(
orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice'])
);
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
} }
// Void any cached key for the order // Void any cached key for the order
$order->objectChanged(); $order->objectChanged();
@@ -88,6 +88,7 @@ function department_outside_hours_prepare_tables(db $db): void
reg_2 VARCHAR(64) NULL, reg_2 VARCHAR(64) NULL,
reg_3 VARCHAR(64) NULL, reg_3 VARCHAR(64) NULL,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
include_in_invoice TINYINT(1) NULL,
wash_id VARCHAR(255) NULL, wash_id VARCHAR(255) NULL,
deleted_at DATETIME NULL deleted_at DATETIME NULL
)' )'
@@ -27,17 +27,21 @@ function department_daily_reports_complaints_openapi_content_or_skip(): string
test()->markTestSkipped('openapi.yaml is not mounted in this test container.'); test()->markTestSkipped('openapi.yaml is not mounted in this test container.');
} }
it('documents the daily report complaints CRUD endpoints and schemas in openapi', function (): void { it('documents the daily report complaints CRUD and customer lookup endpoints in openapi', function (): void {
$content = department_daily_reports_complaints_openapi_content_or_skip(); $content = department_daily_reports_complaints_openapi_content_or_skip();
expect($content)->toContain('/departments/daily-reports/complaints:'); expect($content)->toContain('/departments/daily-reports/complaints:');
expect($content)->toContain('/departments/daily-reports/complaints/customers:');
expect($content)->toContain('operationId: listDailyReportComplaints'); expect($content)->toContain('operationId: listDailyReportComplaints');
expect($content)->toContain('operationId: searchDailyReportComplaintCustomers');
expect($content)->toContain('operationId: createDailyReportComplaint'); expect($content)->toContain('operationId: createDailyReportComplaint');
expect($content)->toContain('operationId: updateDailyReportComplaint'); expect($content)->toContain('operationId: updateDailyReportComplaint');
expect($content)->toContain('operationId: deleteDailyReportComplaint'); expect($content)->toContain('operationId: deleteDailyReportComplaint');
expect($content)->toContain('DepartmentDailyReportComplaintCreateRequest:'); expect($content)->toContain('DepartmentDailyReportComplaintCreateRequest:');
expect($content)->toContain('DepartmentDailyReportComplaintUpdateRequest:'); expect($content)->toContain('DepartmentDailyReportComplaintUpdateRequest:');
expect($content)->toContain('DepartmentDailyReportComplaint:'); expect($content)->toContain('DepartmentDailyReportComplaint:');
expect($content)->toContain('DepartmentDailyReportComplaintCustomerSearchResult:');
expect($content)->toContain('DepartmentDailyReportComplaintCustomerSearchResponse:');
expect($content)->toContain('DepartmentDailyReportComplaintCollectionResponse:'); expect($content)->toContain('DepartmentDailyReportComplaintCollectionResponse:');
expect($content)->toContain('DepartmentDailyReportComplaintDeleteResponse:'); expect($content)->toContain('DepartmentDailyReportComplaintDeleteResponse:');
expect($content)->toContain('department_name:'); expect($content)->toContain('department_name:');
@@ -1,15 +1,20 @@
<?php <?php
it('wires complaint create, list, edit, and delete routes with validation and parsing', function (): void { it('wires complaint create, lookup, list, edit, and delete routes with validation and parsing', function (): void {
$content = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php')); $content = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php'));
expect($content)->toContain('/departments/daily-reports/complaints'); expect($content)->toContain('/departments/daily-reports/complaints');
expect($content)->toContain('/departments/daily-reports/complaints/customers');
expect($content)->toContain("requirePermission('create_department_daily_report_complaints')"); expect($content)->toContain("requirePermission('create_department_daily_report_complaints')");
expect($content)->toContain("requirePermission('list_department_daily_report_complaints')"); expect($content)->toContain("requirePermission('list_department_daily_report_complaints')");
expect($content)->toContain("requirePermission('edit_department_daily_report_complaints')"); expect($content)->toContain("requirePermission('edit_department_daily_report_complaints')");
expect($content)->toContain("requirePermission('delete_department_daily_report_complaints')"); expect($content)->toContain("requirePermission('delete_department_daily_report_complaints')");
expect($content)->toContain("hasPermission('create_department_daily_report_complaints')");
expect($content)->toContain("hasPermission('edit_department_daily_report_complaints')");
expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))"); expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))");
expect($content)->toContain("getOrImportCustomerByCustomerNumber"); expect($content)->toContain("getOrImportCustomerByCustomerNumber");
expect($content)->toContain("Search must be at least 2 characters");
expect($content)->toContain("Failed to fetch complaint customers from e-conomic");
expect($content)->toContain("Description is required"); expect($content)->toContain("Description is required");
expect($content)->toContain("dailyReportComplaintsRepository()->addComplaint"); expect($content)->toContain("dailyReportComplaintsRepository()->addComplaint");
expect($content)->toContain("Complaint not found"); expect($content)->toContain("Complaint not found");
@@ -0,0 +1,80 @@
<?php
app_require('modules/economic/economic_m.php');
app_require('modules/economic/customers/economicCustomers.php');
use customers\economicCustomers;
class economicCustomersDiscountFallbackTestDouble extends economicCustomers
{
/** @var array<int, int|string> */
public array $product_numbers = [];
/** @var array<int, object|\RuntimeException> */
public array $discount_responses = [];
public function __construct()
{
}
public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object
{
return (object)[
'collection' => array_map(
fn (int|string $product_number): object => (object)[
'product' => (object)[
'productNumber' => (string)$product_number,
],
],
$this->product_numbers
),
];
}
public function getCustomerProductDiscount(int $customer_number, int $product_id)
{
$response = $this->discount_responses[$product_id] ?? (object)[
'discountPercentage' => 0,
];
if ($response instanceof \RuntimeException) {
throw $response;
}
return $response;
}
}
it('falls back to a later customer template product when earlier probes fail on missing currency prices', function (): void {
$economic = new economicCustomersDiscountFallbackTestDouble();
$economic->product_numbers = [1, 5, 9];
$economic->discount_responses = [
1 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
5 => (object)['discountPercentage' => 12],
];
expect($economic->getCustomerDiscountPercentage(23152645))->toBe(12);
});
it('returns zero when every customer template lookup fails due to missing currency prices', function (): void {
$economic = new economicCustomersDiscountFallbackTestDouble();
$economic->product_numbers = [1, 2, 3];
$economic->discount_responses = [
1 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
2 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
3 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
];
expect($economic->getCustomerDiscountPercentage(23152645))->toBe(0);
});
it('rethrows unrelated discount lookup failures', function (): void {
$economic = new economicCustomersDiscountFallbackTestDouble();
$economic->product_numbers = [1];
$economic->discount_responses = [
1 => new RuntimeException('HTTP 503 upstream unavailable'),
];
expect(fn () => $economic->getCustomerDiscountPercentage(23152645))
->toThrow(RuntimeException::class, 'HTTP 503 upstream unavailable');
});
@@ -0,0 +1,88 @@
<?php
app_require('classes/economic_v2_versioning_service.php');
app_require('classes/economic_v2_distribution_service.php');
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
if (!class_exists('EconomicV2DistributionServiceOrderOverrideVersioningDouble')) {
class EconomicV2DistributionServiceOrderOverrideVersioningDouble extends economic_v2_versioning_service
{
public function __construct()
{
}
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
{
return null;
}
public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array
{
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
{
return null;
}
public function runBestEffortBackfill(): array
{
return [];
}
}
}
if (!class_exists('EconomicV2DistributionServiceOrderOverrideDouble')) {
class EconomicV2DistributionServiceOrderOverrideDouble extends economic_v2_distribution_service
{
public function __construct()
{
parent::__construct(new EconomicV2DistributionServiceOrderOverrideVersioningDouble());
}
public function exposeIsOrderEligible(array $order): bool
{
return $this->isOrderEligible($order);
}
protected function isDepartmentEligible(int $department_id): bool
{
return $department_id === 12;
}
}
}
it('inherits department eligibility when no order override is set', function (): void {
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
expect($service->exposeIsOrderEligible([
'department_id' => 12,
'include_in_invoice' => null,
]))->toBeTrue();
expect($service->exposeIsOrderEligible([
'department_id' => 13,
'include_in_invoice' => null,
]))->toBeFalse();
});
it('allows an order-level include override to overrule department exclusion', function (): void {
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
expect($service->exposeIsOrderEligible([
'department_id' => 13,
'include_in_invoice' => '1',
]))->toBeTrue();
});
it('allows an order-level exclude override to overrule department inclusion', function (): void {
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
expect($service->exposeIsOrderEligible([
'department_id' => 12,
'include_in_invoice' => '0',
]))->toBeFalse();
});
@@ -0,0 +1,94 @@
<?php
app_require('classes/object_property.php');
app_require('objects/orders_o.php');
use classes\object_property;
use objects\orders_o;
if (!class_exists('OrdersIncludeInInvoiceOverrideOrderDouble')) {
class OrdersIncludeInInvoiceOverrideOrderDouble extends orders_o
{
public bool $departmentIncluded = true;
public function __construct()
{
$this->id = -1;
$this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true);
$this->cashier_id = new object_property('orders', -1, 'cashier_id', 'int', true);
$this->reference = new object_property('orders', -1, 'reference', 'string', false);
$this->notes = new object_property('orders', -1, 'notes', 'string', false);
$this->department_id = new object_property('orders', -1, 'department_id', 'int', true);
$this->reg_1 = new object_property('orders', -1, 'reg_1', 'string', false);
$this->reg_2 = new object_property('orders', -1, 'reg_2', 'string', false);
$this->reg_3 = new object_property('orders', -1, 'reg_3', 'string', false);
$this->created_at = new object_property('orders', -1, 'created_at', 'timestamp', false);
$this->include_in_invoice = new object_property('orders', -1, 'include_in_invoice', 'bool', false);
$this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false);
$this->deleted_at = new object_property('orders', -1, 'deleted_at', 'timestamp', false);
$this->invoice_collection_id = new object_property('orders', -1, 'invoice_collection_id', 'int', false);
$this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false);
$this->wash_id = new object_property('orders', -1, 'wash_id', 'string', false);
$this->lane = new object_property('orders', -1, 'lane', 'string', false);
$this->po = new object_property('orders', -1, 'po', 'string', false);
$this->using_hand_held = new object_property('orders', -1, 'using_hand_held', 'bool', false);
$this->temporary_net_amount = 125.5;
$this->customer_id->set(1234567);
$this->cashier_id->set(42);
$this->department_id->set(12);
$this->created_at->set('2026-04-09 12:34:56');
$this->reference->set('Ref');
}
protected function resolveDepartmentIncludedInInvoicing(): bool
{
return $this->departmentIncluded;
}
public function isPendingHandheld(): bool
{
return false;
}
}
}
it('lets the order-level override include an otherwise excluded order', function (): void {
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
$order->departmentIncluded = false;
$order->include_in_invoice->set(true);
expect($order->getIncludeInInvoiceOverride())->toBeTrue();
expect($order->isIncludedInInvoicing())->toBeTrue();
});
it('lets the order-level override exclude an otherwise included order', function (): void {
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
$order->departmentIncluded = true;
$order->include_in_invoice->set(false);
expect($order->getIncludeInInvoiceOverride())->toBeFalse();
expect($order->isIncludedInInvoicing())->toBeFalse();
});
it('falls back to the department invoicing rule when the override is null', function (): void {
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
$order->departmentIncluded = false;
$order->include_in_invoice->set(null);
expect($order->getIncludeInInvoiceOverride())->toBeNull();
expect($order->isIncludedInInvoicing())->toBeFalse();
});
it('serializes both raw and effective include_in_invoice values', function (): void {
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
$order->departmentIncluded = true;
$order->include_in_invoice->set(null);
expect($order->asArray(true, false))->toMatchArray([
'include_in_invoice' => null,
'include_in_invoice_effective' => true,
'created_at' => '2026-04-09 12:34:56',
'total_net_amount' => 125.5,
]);
});
@@ -0,0 +1,28 @@
<?php
app_require('classes/orders_input_normalizer.php');
use classes\orders_input_normalizer;
it('normalizes sql and datetime-local created_at values', function (): void {
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09 12:34:56'))->toBe('2026-04-09 12:34:56');
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09T12:34'))->toBe('2026-04-09 12:34:00');
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09T12:34:56'))->toBe('2026-04-09 12:34:56');
});
it('rejects invalid created_at values', function (): void {
orders_input_normalizer::normalizeCreatedAt('2026/04/09 12:34');
})->throws(InvalidArgumentException::class, 'created_at must be a valid datetime');
it('normalizes include_in_invoice tri-state inputs', function (): void {
expect(orders_input_normalizer::normalizeIncludeInInvoice(null))->toBeNull();
expect(orders_input_normalizer::normalizeIncludeInInvoice('use_department'))->toBeNull();
expect(orders_input_normalizer::normalizeIncludeInInvoice('include'))->toBeTrue();
expect(orders_input_normalizer::normalizeIncludeInInvoice('exclude'))->toBeFalse();
expect(orders_input_normalizer::normalizeIncludeInInvoice(true))->toBeTrue();
expect(orders_input_normalizer::normalizeIncludeInInvoice(false))->toBeFalse();
});
it('rejects invalid include_in_invoice values', function (): void {
orders_input_normalizer::normalizeIncludeInInvoice('maybe');
})->throws(InvalidArgumentException::class, 'include_in_invoice must be use_department, include, or exclude');
@@ -0,0 +1,24 @@
<?php
it('wires order create and update routes through the settings normalizers', function (): void {
$routeFile = app_path('routes/ordersRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('use classes\orders_input_normalizer;');
expect($content)->toContain("orders_input_normalizer::normalizeCreatedAt(\$data['created_at'] ?? date('Y-m-d H:i:s'))");
expect($content)->toContain("array_key_exists('include_in_invoice', \$data)");
expect($content)->toContain("orders_input_normalizer::normalizeIncludeInInvoice(\$data['include_in_invoice'])");
expect($content)->toContain("\$order->include_in_invoice->set(");
expect($content)->toContain("\$response->error(\$e->getMessage(), 400);");
});
it('keeps line-item invoice filtering in the order net amount calculation', function (): void {
$objectFile = app_path('objects/orders_o.php');
$content = file_get_contents($objectFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("'include_in_invoice' => \$this->getIncludeInInvoiceOverride()");
expect($content)->toContain("'include_in_invoice_effective' => \$this->isIncludedInInvoicing()");
expect($content)->toContain("if (!\$item['include_in_invoice'])");
});
@@ -6,6 +6,8 @@ it('uses the same default broker url and shared secret fallback as the docker st
expect($source)->not->toBeFalse(); expect($source)->not->toBeFalse();
expect($source)->toContain("private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';"); expect($source)->toContain("private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';");
expect($source)->toContain("private const DEFAULT_SHARED_SECRET = 'truckwash-edge-dev';"); expect($source)->toContain("private const DEFAULT_SHARED_SECRET = 'truckwash-edge-dev';");
expect($source)->toContain('class edge_broker_transport_exception extends Exception');
expect($source)->toContain('class edge_broker_http_exception extends Exception');
expect($source)->toContain("getenv('EDGE_BROKER_SHARED_SECRET')"); expect($source)->toContain("getenv('EDGE_BROKER_SHARED_SECRET')");
expect($source)->toContain("getenv('EDGE_INTERNAL_SECRET')"); expect($source)->toContain("getenv('EDGE_INTERNAL_SECRET')");
}); });
@@ -0,0 +1,20 @@
<?php
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'));
expect($source)->not->toBeFalse();
expect($source)->toContain('public function pollCommand');
expect($source)->toContain('public function submitCommandResult');
expect($source)->toContain('private function tryImmediateBrokerDispatch');
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("\$updateJob->status->set(\$ok ? 'COMPLETED' : 'FAILED');");
expect($source)->not->toContain("\$gateway->discovery_status->set((string)(\$payload['discovery_status'] ?? \$gateway->discovery_status->value()));");
});
@@ -15,6 +15,8 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
{ {
$originalServer = $_SERVER; $originalServer = $_SERVER;
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL'); $originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
$originalBrokerPublicUrl = getenv('EDGE_BROKER_PUBLIC_URL');
$originalBrokerPublicPort = getenv('EDGE_BROKER_PUBLIC_PORT');
$_SERVER = $server; $_SERVER = $server;
@@ -27,6 +29,16 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
} else { } else {
putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl); putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl);
} }
if ($originalBrokerPublicUrl === false) {
putenv('EDGE_BROKER_PUBLIC_URL');
} else {
putenv('EDGE_BROKER_PUBLIC_URL=' . $originalBrokerPublicUrl);
}
if ($originalBrokerPublicPort === false) {
putenv('EDGE_BROKER_PUBLIC_PORT');
} else {
putenv('EDGE_BROKER_PUBLIC_PORT=' . $originalBrokerPublicPort);
}
} }
} }
@@ -89,3 +101,44 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1'); expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
}); });
}); });
it('normalizes public broker overrides to https and browser shell urls to wss', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
], function (): void {
putenv('EDGE_BROKER_PUBLIC_URL=http://api.truckwash.io:4300');
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getBrokerPublicUrl())->toBe('https://api.truckwash.io:4300');
expect($manager->buildBrowserShellWsUrl('shell-token'))->toBe('wss://api.truckwash.io:4300/ws/browser-shell?token=shell-token');
});
});
it('keeps localhost broker overrides on plain http and ws for local development', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost:5173',
'HTTP_X_FORWARDED_PROTO' => 'http',
], function (): void {
putenv('EDGE_BROKER_PUBLIC_URL=http://localhost:4300');
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getBrokerPublicUrl())->toBe('http://localhost:4300');
expect($manager->buildBrowserShellWsUrl('shell-token'))->toBe('ws://localhost:4300/ws/browser-shell?token=shell-token');
});
});
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)->toContain('apt-get install -y curl ca-certificates nodejs npm python3 make g++');
expect($script)->toContain('npm install --omit=dev');
});
});
@@ -15,7 +15,7 @@ it('registers the edge gateway management REST endpoints', function (): void {
expect($route)->toContain("'/departments/{id}/gateway-cutover'"); expect($route)->toContain("'/departments/{id}/gateway-cutover'");
}); });
it('registers public installer, claim, heartbeat, and internal broker auth endpoints', function (): void { it('registers public installer, claim, heartbeat, command polling, and internal broker auth endpoints', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php')); $route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->toContain("'/edge-agent/install.sh'"); expect($route)->toContain("'/edge-agent/install.sh'");
@@ -23,6 +23,8 @@ it('registers public installer, claim, heartbeat, and internal broker auth endpo
expect($route)->toContain("'/edge-agent/artifacts/package.json'"); expect($route)->toContain("'/edge-agent/artifacts/package.json'");
expect($route)->toContain("'/edge-agent/claim'"); expect($route)->toContain("'/edge-agent/claim'");
expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'"); expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
expect($route)->toContain("'/edge-agent/internal/agent/auth'"); expect($route)->toContain("'/edge-agent/internal/agent/auth'");
expect($route)->toContain("'/edge-agent/internal/shell/auth'"); expect($route)->toContain("'/edge-agent/internal/shell/auth'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/{id}/close'"); expect($route)->toContain("'/edge-agent/internal/shell-sessions/{id}/close'");
@@ -18,6 +18,7 @@ it('stores edge gateway heartbeats, bindings, and shell transcript data', functi
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php')); $bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
expect($bootstrapContent)->toContain('last_heartbeat_at DATETIME NULL'); 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('channel INT NOT NULL DEFAULT 0');
expect($bootstrapContent)->toContain('transcript_text LONGTEXT NULL'); expect($bootstrapContent)->toContain('transcript_text LONGTEXT NULL');
expect($bootstrapContent)->toContain('context_json JSON NULL'); expect($bootstrapContent)->toContain('context_json JSON NULL');