From 22f856c62a4f81e7036e0bbd71eb7b8527474fde Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Thu, 9 Apr 2026 12:24:22 +0200 Subject: [PATCH] Add unit tests for invoicing, orders normalization, gateway commands, and department complaints. Update schema bootstraps and improve agent command execution logic. --- openapi.yaml | 527 +++++++++++++++++- services/edge-agent/dist/agent.mjs | 264 +++++++-- services/edge-agent/dist/package.json | 1 + services/edge-agent/package-lock.json | 17 + services/edge-agent/package.json | 1 + services/edge-agent/test/agent.test.mjs | 190 ++++++- services/edge-broker/server.mjs | 10 + services/edge-broker/test/broker.test.mjs | 46 ++ .../economic_v2_distribution_service.php | 46 +- .../nginx/app/classes/edge_broker_client.php | 31 +- .../app/classes/edge_gateway_manager.php | 489 +++++++++++++--- .../classes/edge_gateway_schema_bootstrap.php | 34 ++ .../app/classes/orders_input_normalizer.php | 66 +++ .../app/classes/orders_schema_bootstrap.php | 32 ++ .../economic/customers/economicCustomers.php | 51 +- .../objects/edge_gateway_update_jobs_o.php | 3 + services/nginx/app/objects/orders_o.php | 44 ++ services/nginx/app/openapi.yaml | 47 ++ .../nginx/app/routes/InvoicingPeriodRoute.php | 2 +- .../routes/departmentDailyReportsRoute.php | 134 +++++ .../nginx/app/routes/edgeGatewaysRoute.php | 41 ++ services/nginx/app/routes/ordersRoute.php | 29 +- ...eHoursStatisticsServiceIntegrationTest.php | 1 + ...tDailyReportsComplaintsOpenApiSpecTest.php | 6 +- ...ailyReportsComplaintsRouteContractTest.php | 7 +- .../EconomicCustomersDiscountFallbackTest.php | 80 +++ ...V2DistributionServiceOrderOverrideTest.php | 88 +++ .../OrdersIncludeInInvoiceOverrideTest.php | 94 ++++ .../Unit/Orders/OrdersInputNormalizerTest.php | 28 + .../OrdersRouteSettingsUpdateWiringTest.php | 24 + .../Selfserve/EdgeBrokerClientConfigTest.php | 2 + .../EdgeGatewayManagerCommandQueueTest.php | 20 + .../Selfserve/EdgeGatewayManagerUrlTest.php | 53 ++ .../Selfserve/EdgeGatewayRouteWiringTest.php | 4 +- .../EdgeGatewaySchemaBootstrapTest.php | 1 + 35 files changed, 2346 insertions(+), 167 deletions(-) create mode 100644 services/nginx/app/classes/orders_input_normalizer.php create mode 100644 services/nginx/app/classes/orders_schema_bootstrap.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceOrderOverrideTest.php create mode 100644 services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php create mode 100644 services/nginx/app/tests/Unit/Orders/OrdersInputNormalizerTest.php create mode 100644 services/nginx/app/tests/Unit/Orders/OrdersRouteSettingsUpdateWiringTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php diff --git a/openapi.yaml b/openapi.yaml index 1cc39a8a..41a53da2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -901,14 +901,16 @@ paths: summary: Process inbound Bird voice call lifecycle operationId: birdInboundVoiceCallWebhook description: > - Stateful inbound-call webhook that answers the call immediately, tracks DTMF input, and enforces a timeout hangup flow. - During the first 300 seconds from call start, DTMF input is extracted from fixed payload fields - (`dtmf`, `digit`, `digits`, `keys`, and known nested variants). A Slack message is sent whenever any DTMF - value is captured, the call acknowledges input, and gather is re-issued with retry-loop semantics - checking for input every 2 seconds until a digit is entered. 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. Call lifecycle - state is only finalized and cleared after terminal status is observed. + Stateful inbound-call webhook that answers the call immediately, runs a DTMF-driven IVR for + phone-controlled entrance and exit gates, and enforces a timeout hangup flow. During the + first 300 seconds from call start, DTMF input is extracted from payload fields such as + `dtmf`, `digit`, `digits`, `keys`, `result.keys`, and nested `conditions[].value`. Values + like `1#` are normalized to a single menu digit before the route resolves the department + selection and then the gate type selection (`1=entrance`, `2=exit`). When a valid gate is + resolved, the webhook triggers the corresponding `department_gates` phone-call gate, announces + 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: - in: query name: workspaceId @@ -9878,6 +9880,33 @@ paths: '403': $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 /economic/config: get: @@ -10938,6 +10967,127 @@ paths: application/json: 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: get: tags: @@ -11262,6 +11412,245 @@ components: type: integer 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: type: string enum: @@ -16077,16 +16466,20 @@ components: example: ongoing dtmf: type: string - description: DTMF value when present + description: DTMF value when present, for example `1` or `1#` example: "5" digit: type: string - description: Alternate DTMF field + description: Alternate DTMF field, also accepts values such as `1#` example: "5" digits: type: string description: Alternate DTMF field example: "5" + keys: + type: string + description: Alternate DTMF field returned by gather results + example: "1#" call: type: object additionalProperties: true @@ -16110,7 +16503,11 @@ components: properties: phase: 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: type: string example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" @@ -16131,6 +16528,19 @@ components: last_input: type: string 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: type: integer nullable: true @@ -16616,6 +17026,101 @@ components: type: array 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: type: object properties: diff --git a/services/edge-agent/dist/agent.mjs b/services/edge-agent/dist/agent.mjs index 62ea16e9..9c19a410 100644 --- a/services/edge-agent/dist/agent.mjs +++ b/services/edge-agent/dist/agent.mjs @@ -1,23 +1,22 @@ import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import os from "node:os"; -import { spawn } from "node:child_process"; import process from "node:process"; import WebSocket from "ws"; const DEFAULT_VERSION = "0.1.0"; const DEFAULT_RECONNECT_DELAY_MS = 5000; +const DEFAULT_SHELL_COLS = 120; +const DEFAULT_SHELL_ROWS = 32; function buildBrokerHeartbeatState(isConnected) { return isConnected ? { status: "ONLINE", - discovery_status: "READY", metadata: { broker_connected: true }, } : { status: "DEGRADED", - discovery_status: "BROKER_DISCONNECTED", metadata: { broker_connected: false }, }; } @@ -219,52 +218,123 @@ function defaultShellCommand() { 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 open = (payload = {}) => { - const sessionId = String(payload.sessionId); - const shell = payload.shellCommand ? { command: payload.shellCommand, args: payload.shellArgs || [] } : defaultShellCommand(); - const proc = spawn(shell.command, shell.args, { - cwd: payload.cwd || process.cwd(), - env: process.env, - stdio: "pipe", - }); + const open = async (payload = {}) => { + const sessionId = String(payload.sessionId || ""); + if (sessionId === "") { + return; + } - proc.stdout.on("data", (chunk) => { - sendMessage({ type: "SHELL_OUTPUT", sessionId, data: chunk.toString("utf8") }); - }); - proc.stderr.on("data", (chunk) => { - sendMessage({ type: "SHELL_OUTPUT", sessionId, data: chunk.toString("utf8") }); - }); - proc.on("close", (code) => { + const existingSession = sessions.get(sessionId); + if (existingSession) { + existingSession.pty.kill(); sessions.delete(sessionId); - sendMessage({ type: "SHELL_EXIT", sessionId, code }); - }); + } - sessions.set(sessionId, proc); - sendMessage({ type: "SHELL_OPENED", sessionId }); + const shell = payload.shellCommand + ? { 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 proc = sessions.get(String(payload.sessionId)); - if (!proc) { + const sessionRecord = sessions.get(String(payload.sessionId || "")); + if (!sessionRecord) { 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 sessionId = String(payload.sessionId); - const proc = sessions.get(sessionId); - if (!proc) { + const sessionId = String(payload.sessionId || ""); + const sessionRecord = sessions.get(sessionId); + if (!sessionRecord) { return; } - proc.kill(); - sessions.delete(sessionId); + + sessionRecord.pty.kill(); }; - return { open, input, close }; + return { open, input, resize, close }; } export async function handleAgentCommand(command, deps = {}) { @@ -288,18 +358,26 @@ export async function handleAgentCommand(command, deps = {}) { } export function buildHeartbeatPayload(config, extra = {}) { - return { + const payload = { hostname: os.hostname(), installed_version: config.installedVersion || DEFAULT_VERSION, target_version: config.targetVersion || config.installedVersion || DEFAULT_VERSION, status: extra.status || "ONLINE", - discovery_status: extra.discovery_status || "READY", - inventory: extra.inventory || [], metadata: { release_channel: config.releaseChannel || "stable", ...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 = {}) { @@ -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( config, { fetchImpl = fetch, wsFactory = (url) => new WebSocket(url), + createShellBridgeImpl = createShellBridge, onOpen = null, onClose = null, onError = null, } = {} ) { - const shell = createShellBridge((message) => { + const shell = createShellBridgeImpl((message) => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message)); } @@ -352,9 +493,13 @@ export function connectBroker( const message = JSON.parse(raw.toString()); try { if (message.type === "COMMAND") { + const normalizedPayload = + message.payload && typeof message.payload === "object" && message.payload.payload + ? message.payload.payload + : (message.payload || {}); const payload = await handleAgentCommand({ commandType: message.commandType, - payload: message.payload || {}, + payload: normalizedPayload, }, { fetchImpl }); socket.send(JSON.stringify({ type: "COMMAND_RESULT", @@ -366,19 +511,23 @@ export function connectBroker( } if (message.type === "OPEN_ROOT_SHELL") { - shell.open(message.payload || {}); + await shell.open(message.payload || {}); } else if (message.type === "SHELL_INPUT") { shell.input(message.payload || {}); + } else if (message.type === "RESIZE_ROOT_SHELL") { + shell.resize(message.payload || {}); } else if (message.type === "CLOSE_ROOT_SHELL") { shell.close(message.payload || {}); } } catch (error) { - socket.send(JSON.stringify({ - type: "COMMAND_RESULT", - commandId: message.commandId, - ok: false, - error: error instanceof Error ? error.message : String(error), - })); + if (message.type === "COMMAND") { + socket.send(JSON.stringify({ + type: "COMMAND_RESULT", + commandId: message.commandId, + 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); const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000; 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 socket = null; @@ -417,6 +568,29 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } = }, 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 = () => { if (stopped) { return; @@ -442,6 +616,7 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } = await sendBrokerAwareHeartbeat(); openBrokerConnection(); + const commandPollPromise = runCommandPollLoop(); const timer = setInterval(() => { sendBrokerAwareHeartbeat().catch(() => {}); @@ -458,6 +633,7 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } = }; return { + commandPollPromise, get socket() { return socket; }, diff --git a/services/edge-agent/dist/package.json b/services/edge-agent/dist/package.json index fa659526..a73ca764 100644 --- a/services/edge-agent/dist/package.json +++ b/services/edge-agent/dist/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "dependencies": { + "node-pty": "^1.1.0", "ws": "^8.18.0" } } diff --git a/services/edge-agent/package-lock.json b/services/edge-agent/package-lock.json index f608b8bd..1b14f893 100644 --- a/services/edge-agent/package-lock.json +++ b/services/edge-agent/package-lock.json @@ -6,9 +6,26 @@ "": { "name": "truckwash-edge-agent", "dependencies": { + "node-pty": "^1.1.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": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/services/edge-agent/package.json b/services/edge-agent/package.json index cc49a6ef..e5fa5bca 100644 --- a/services/edge-agent/package.json +++ b/services/edge-agent/package.json @@ -6,6 +6,7 @@ "test": "node --test" }, "dependencies": { + "node-pty": "^1.1.0", "ws": "^8.18.0" } } diff --git a/services/edge-agent/test/agent.test.mjs b/services/edge-agent/test/agent.test.mjs index aeb7dff2..0202404a 100644 --- a/services/edge-agent/test/agent.test.mjs +++ b/services/edge-agent/test/agent.test.mjs @@ -9,8 +9,8 @@ import { claimIfNeeded, createShellBridge, getRelayStatus, - startAgent, setRelayState, + startAgent, } from "../dist/agent.mjs"; 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); }); -test("shell bridge streams child process output", async () => { +test("shell bridge proxies PTY output, input, resize, and close events", async () => { 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", - shellCommand: process.execPath, - shellArgs: ["-e", "process.stdin.on('data', (d) => process.stdout.write(d))"], + shellCommand: "/bin/bash", + shellArgs: ["-l"], + cols: 90, + rows: 24, + cwd: "/tmp", }); - await new Promise((resolve) => setTimeout(resolve, 50)); - shell.input({ sessionId: "test-shell", data: "hello\n" }); - await new Promise((resolve) => setTimeout(resolve, 50)); + createdPtys[0].emitData("root@pi:~# "); + shell.input({ sessionId: "test-shell", data: "ls\r" }); + shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 }); shell.close({ sessionId: "test-shell" }); - await new Promise((resolve) => setTimeout(resolve, 50)); - assert.ok(messages.some((message) => message.type === "SHELL_OPENED")); - assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("hello"))); + assert.equal(createdPtys.length, 1); + 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 { constructor(url) { super(); @@ -129,21 +192,85 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while agentToken: "agent-token", heartbeatIntervalSeconds: 60, reconnectDelayMs: 10, + commandPollTimeoutSeconds: 0, + commandPollRetryDelayMs: 5, })); const heartbeats = []; - const fakeFetch = async (url, options = {}) => { - heartbeats.push({ - url, - body: JSON.parse(options.body ?? "{}"), - }); + const resultPosts = []; + let polledCommandDelivered = false; - return { - ok: true, - async json() { - return { data: { ok: true } }; - }, - }; + const fakeFetch = async (url, options = {}) => { + const body = options.body ? JSON.parse(options.body) : {}; + + 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 = []; @@ -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.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); + 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].emit("open"); 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"); 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); agent.stop(); diff --git a/services/edge-broker/server.mjs b/services/edge-broker/server.mjs index 2375e025..230a1a98 100644 --- a/services/edge-broker/server.mjs +++ b/services/edge-broker/server.mjs @@ -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") { agent.send(JSON.stringify({ type: "CLOSE_ROOT_SHELL", diff --git a/services/edge-broker/test/broker.test.mjs b/services/edge-broker/test/broker.test.mjs index 10ba9a85..97cb360a 100644 --- a/services/edge-broker/test/broker.test.mjs +++ b/services/edge-broker/test/broker.test.mjs @@ -97,3 +97,49 @@ test("broker bridges browser shell sessions through the connected agent", async agent.terminate(); 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(); +}); diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php index ce7b670e..6bfd5941 100644 --- a/services/nginx/app/classes/economic_v2_distribution_service.php +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -363,7 +363,8 @@ class economic_v2_distribution_service $department_id = (int)$order['department_id']; $created_at = (string)$order['created_at']; - if (!$this->isDepartmentEligible($department_id)) { + $order_is_included = $this->isOrderEligible($order); + if (!$order_is_included) { 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; 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; @@ -1044,7 +1045,7 @@ class economic_v2_distribution_service if (!$this->isSystemOrderCandidate($order, self::FIXED_PRICING_SYSTEM_ORDER_REFERENCE)) { continue; } - } elseif (!$this->isDepartmentEligible($department_id)) { + } elseif (!$this->isOrderEligible($order)) { continue; } @@ -1087,7 +1088,13 @@ class economic_v2_distribution_service $groups[$group_key]['order_ids'][] = $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)) { continue; } - } elseif (!$this->isDepartmentEligible($department_id)) { + } elseif (!$this->isOrderEligible($order)) { continue; } @@ -1179,7 +1186,13 @@ class economic_v2_distribution_service } 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; $from = $db->escape_string($from_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 WHERE deleted_at IS NULL 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); return [ @@ -1549,10 +1562,25 @@ class economic_v2_distribution_service 'amount' => round((float)($amount ?? (float)$order->getNetAmount()), 5), 'booked' => $order->isBooked(true), '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 { if (!isset($this->customer_name_cache[$customer_number])) { diff --git a/services/nginx/app/classes/edge_broker_client.php b/services/nginx/app/classes/edge_broker_client.php index a475db13..0150d214 100644 --- a/services/nginx/app/classes/edge_broker_client.php +++ b/services/nginx/app/classes/edge_broker_client.php @@ -4,6 +4,32 @@ namespace classes; 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 { private const DEFAULT_BROKER_URL = 'http://edge-broker:4300'; @@ -99,11 +125,12 @@ class edge_broker_client $rawResponse = curl_exec($ch); $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); $curlError = curl_error($ch); curl_close($ch); 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); @@ -111,7 +138,7 @@ class edge_broker_client $message = is_array($decoded) ? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed') : 'Edge broker request failed'; - throw new Exception($message); + throw new edge_broker_http_exception($message, $statusCode); } return $decoded; diff --git a/services/nginx/app/classes/edge_gateway_manager.php b/services/nginx/app/classes/edge_gateway_manager.php index 82d6a204..fc926fc3 100644 --- a/services/nginx/app/classes/edge_gateway_manager.php +++ b/services/nginx/app/classes/edge_gateway_manager.php @@ -28,6 +28,10 @@ class edge_gateway_manager public const SHELL_SESSION_TTL_SECONDS = 900; public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60; 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) { @@ -168,7 +172,6 @@ class edge_gateway_manager $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value()); $gateway->last_heartbeat_at->set($this->now()); $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() ?? [])); 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 { - $gateway = $this->requireGateway($gatewayId); - $job = $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId); - $response = $this->dispatchCommandJob($job, $gateway, [ - 'requestedBy' => $userId, - ]); - - if (isset($response['inventory']) && is_array($response['inventory'])) { - $this->syncDeviceInventory($gatewayId, $response['inventory']); - } + $gateway = $this->requireDispatchableGateway($gatewayId); + $gateway->discovery_status->set('PENDING'); + $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId); 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 { - $gateway = $this->requireGateway($gatewayId); + $gateway = $this->requireDispatchableGateway($gatewayId); + $gateway->target_version->set($targetVersion); + $jobObject = new edge_gateway_update_jobs_o(); $jobId = $jobObject->add_object([ 'gateway_id' => $gatewayId, + 'command_job_id' => null, 'target_version' => $targetVersion, 'release_channel' => $releaseChannel, 'status' => 'PENDING', @@ -359,22 +359,7 @@ class edge_gateway_manager 'targetVersion' => $targetVersion, 'releaseChannel' => $releaseChannel, ], $userId); - - 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; - } + $jobObject->command_job_id->set((int)$command->id); $this->writeAudit( $gatewayId, @@ -387,6 +372,71 @@ class edge_gateway_manager 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 */ @@ -502,7 +552,7 @@ class edge_gateway_manager public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array { $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', [ 'relayId' => $logicalRelayId, 'deviceId' => $binding['device_id'], @@ -510,12 +560,9 @@ class edge_gateway_manager 'channel' => (int)$binding['channel'], ], null); - return $this->dispatchCommandJob($job, $gateway, [ - 'relayId' => $logicalRelayId, - 'deviceId' => $binding['device_id'], - 'localIp' => $binding['local_ip'], - 'channel' => (int)$binding['channel'], - ]); + $this->tryImmediateBrokerDispatch($job, $gateway); + + return $this->waitForCommandResult((int)$job->id); } /** @@ -524,7 +571,7 @@ class edge_gateway_manager public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array { $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', [ 'relayId' => $logicalRelayId, 'deviceId' => $binding['device_id'], @@ -533,13 +580,9 @@ class edge_gateway_manager 'on' => $on, ], null); - return $this->dispatchCommandJob($job, $gateway, [ - 'relayId' => $logicalRelayId, - 'deviceId' => $binding['device_id'], - 'localIp' => $binding['local_ip'], - 'channel' => (int)$binding['channel'], - 'on' => $on, - ]); + $this->tryImmediateBrokerDispatch($job, $gateway); + + return $this->waitForCommandResult((int)$job->id); } /** @@ -592,7 +635,7 @@ INSTALL_DIR=/opt/truckwash-edge-agent mkdir -p "$INSTALL_DIR" export DEBIAN_FRONTEND=noninteractive 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 "__AGENT_URL__" -o "$INSTALL_DIR/agent.mjs" cat > "$INSTALL_DIR/config.json" <<'EOF_JSON' @@ -654,7 +697,7 @@ BASH; { $configured = trim((string)(getenv('EDGE_BROKER_PUBLIC_URL') ?: '')); if ($configured !== '') { - return $configured; + return $this->normalizeBrokerPublicUrl($configured); } $apiBaseUrl = $this->getApiBaseUrl(); @@ -665,7 +708,7 @@ BASH; $scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'https' : 'http'; $port = getenv('EDGE_BROKER_PUBLIC_PORT') ?: '4300'; - return $scheme . '://' . $parsed['host'] . ':' . $port; + return $this->normalizeBrokerPublicUrl($scheme . '://' . $parsed['host'] . ':' . $port); } public function getApiBaseUrl(): string @@ -740,6 +783,76 @@ BASH; 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 */ @@ -856,32 +969,282 @@ BASH; /** * @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( - (int)$gateway->id, - (string)$job->command_type->value(), - [ - 'jobId' => (int)$job->id, - 'gatewayId' => (int)$gateway->id, - 'departmentId' => (int)$gateway->department_id->value(), - 'payload' => $payload, - ] - ); + try { + $response = $this->broker()->dispatchCommand( + (int)$gateway->id, + (string)$job->command_type->value(), + $this->buildCommandExecutionPayload($job, $gateway) + ); + + $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->completed_at->set($this->now()); - - $ok = (bool)($response['ok'] ?? false); + $job->error_message->set($ok ? null : $errorMessage); $job->status->set($ok ? 'COMPLETED' : 'FAILED'); - if (!$ok) { - $job->error_message->set((string)($response['error'] ?? 'Edge broker command failed')); - throw new Exception((string)($response['error'] ?? 'Edge broker command failed')); + + $this->applyCommandResult($gatewayObject, $job, $ok, $payload, $errorMessage); + } + + 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']); } /** diff --git a/services/nginx/app/classes/edge_gateway_schema_bootstrap.php b/services/nginx/app/classes/edge_gateway_schema_bootstrap.php index cac4f839..adb4071f 100644 --- a/services/nginx/app/classes/edge_gateway_schema_bootstrap.php +++ b/services/nginx/app/classes/edge_gateway_schema_bootstrap.php @@ -125,6 +125,7 @@ class edge_gateway_schema_bootstrap "CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs ( id INT AUTO_INCREMENT PRIMARY KEY, gateway_id INT NOT NULL, + command_job_id INT NULL, target_version VARCHAR(64) NOT NULL, release_channel VARCHAR(32) NOT NULL DEFAULT 'stable', 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, deleted_at TIMESTAMP NULL DEFAULT NULL, INDEX idx_edge_gateway_update_gateway (gateway_id), + INDEX idx_edge_gateway_update_command (command_job_id), INDEX idx_edge_gateway_update_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", @@ -182,6 +184,38 @@ class edge_gateway_schema_bootstrap $db->query($sql); } + if (!self::tableHasColumn('edge_gateway_update_jobs', 'command_job_id')) { + $db->query( + "ALTER TABLE edge_gateway_update_jobs + ADD COLUMN command_job_id INT NULL AFTER gateway_id, + ADD INDEX idx_edge_gateway_update_command (command_job_id)" + ); + } + self::$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; + } } diff --git a/services/nginx/app/classes/orders_input_normalizer.php b/services/nginx/app/classes/orders_input_normalizer.php new file mode 100644 index 00000000..71b1b9a4 --- /dev/null +++ b/services/nginx/app/classes/orders_input_normalizer.php @@ -0,0 +1,66 @@ +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'); + } +} diff --git a/services/nginx/app/classes/orders_schema_bootstrap.php b/services/nginx/app/classes/orders_schema_bootstrap.php new file mode 100644 index 00000000..d79fa52b --- /dev/null +++ b/services/nginx/app/classes/orders_schema_bootstrap.php @@ -0,0 +1,32 @@ +query( + "ALTER TABLE orders + ADD COLUMN IF NOT EXISTS include_in_invoice TINYINT(1) NULL DEFAULT NULL + AFTER created_at" + ); + + self::$initialized = true; + } +} diff --git a/services/nginx/app/modules/economic/customers/economicCustomers.php b/services/nginx/app/modules/economic/customers/economicCustomers.php index 16c47779..425eef92 100644 --- a/services/nginx/app/modules/economic/customers/economicCustomers.php +++ b/services/nginx/app/modules/economic/customers/economicCustomers.php @@ -73,11 +73,52 @@ class economicCustomers extends economic_m if ($customer_number === 0) { return 0; } - // Get the customer products - $products = $this->getCustomerProducts($customer_number, 1)->collection; - $discount = $this->getCustomerProductDiscount($customer_number, $products[0]->product->productNumber); - // Since the discount is global, we only need to get the discount for one product - return $discount->discountPercentage ?? 0; + // The discount is global, but e-conomic resolves it through a product-specific + // invoice-line template. For foreign-currency customers some templates can fail + // if that product has no price in the customer currency, so try a few products + // before falling back to zero. + $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 diff --git a/services/nginx/app/objects/edge_gateway_update_jobs_o.php b/services/nginx/app/objects/edge_gateway_update_jobs_o.php index 268490a8..c7e2df1b 100644 --- a/services/nginx/app/objects/edge_gateway_update_jobs_o.php +++ b/services/nginx/app/objects/edge_gateway_update_jobs_o.php @@ -12,6 +12,7 @@ class edge_gateway_update_jobs_o extends db use db_object_t; public object_property $gateway_id; + public object_property $command_job_id; public object_property $target_version; public object_property $release_channel; public object_property $status; @@ -33,6 +34,7 @@ class edge_gateway_update_jobs_o extends db public function getObjectProperties(): void { $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); + $this->command_job_id = new object_property($this->table, $this->id, 'command_job_id', 'int', false); $this->target_version = new object_property($this->table, $this->id, 'target_version', 'string', false); $this->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false); $this->status = new object_property($this->table, $this->id, 'status', 'string', false); @@ -57,6 +59,7 @@ class edge_gateway_update_jobs_o extends db return [ 'id' => (int)$this->id, 'gateway_id' => (int)$this->gateway_id->value(), + 'command_job_id' => $this->command_job_id->value() === null ? null : (int)$this->command_job_id->value(), 'target_version' => (string)$this->target_version->value(), 'release_channel' => (string)$this->release_channel->value(), 'status' => (string)$this->status->value(), diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index b95e4fc5..4b18ccec 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -6,6 +6,7 @@ use attachments\helpers\attachment_content; use classes\db; use classes\email; use classes\invoicing_period_utils; +use classes\orders_schema_bootstrap; use classes\pdf_generator; use classes\motorapi; use classes\object_property; @@ -31,6 +32,7 @@ class orders_o extends db public object_property $reg_3; public economic_module_orders $economic_module_orders; public object_property $created_at; + public object_property $include_in_invoice; public object_property $deleted_at; public object_property $completed_at; @@ -53,6 +55,7 @@ class orders_o extends db public function structure(): void { + orders_schema_bootstrap::ensureTables(); $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_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->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->economic_module_orders = (new economic_module_orders())->getByOrderId($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(), 'completed_at' => $this->completed_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(), 'total_net_amount' => $this->temporary_net_amount ?: $this->getNetAmount(), 'invoice_collection_id' => (int)$this->invoice_collection_id->value(), @@ -1491,6 +1497,44 @@ class orders_o extends db public function isIncludedInInvoicing(): bool { 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(); } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 7a809600..41a53da2 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -11059,6 +11059,35 @@ paths: 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: get: tags: @@ -17024,6 +17053,15 @@ components: minLength: 1 maxLength: 4000 + DepartmentDailyReportComplaintCustomerSearchResult: + type: object + properties: + customer_number: + type: integer + customer_name: + type: string + nullable: true + DepartmentDailyReportComplaint: type: object properties: @@ -17065,6 +17103,15 @@ components: 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: diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 208d0b53..1cf90a8e 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -1327,7 +1327,7 @@ class InvoicingPeriodRoute 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier 'booked' => $transaction->isBooked(true), 'department_id' => $departmentId, - 'excluded' => self::isDepartmentExcludedFromInvoicingCached($departmentId), + 'excluded' => !$transaction->isIncludedInInvoicing(), 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, 'queue_status' => null, 'queue_job_id' => null, diff --git a/services/nginx/app/routes/departmentDailyReportsRoute.php b/services/nginx/app/routes/departmentDailyReportsRoute.php index 529d364a..2ab6c43a 100644 --- a/services/nginx/app/routes/departmentDailyReportsRoute.php +++ b/services/nginx/app/routes/departmentDailyReportsRoute.php @@ -5,6 +5,7 @@ namespace routes; use classes\authentication; use classes\department_outside_hours_statistics_service; use classes\workfeed; +use customers\economicCustomers; use DateInterval; use DateTime; 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 () { global $response; $this->requirePermission('list_department_daily_report_complaints'); @@ -1901,11 +2015,31 @@ class departmentDailyReportsRoute return new department_daily_report_complaints_o(); } + protected function complaintCustomerSearchService(): economicCustomers + { + return new economicCustomers(); + } + protected function outsideHoursStatisticsService(): 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 { if (is_array($department)) { diff --git a/services/nginx/app/routes/edgeGatewaysRoute.php b/services/nginx/app/routes/edgeGatewaysRoute.php index 2e5bac30..96bb5333 100644 --- a/services/nginx/app/routes/edgeGatewaysRoute.php +++ b/services/nginx/app/routes/edgeGatewaysRoute.php @@ -185,6 +185,8 @@ class edgeGatewaysRoute $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/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/shell/auth', fn() => $this->handleInternalShellAuth()); $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)); } + 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 { global /** @var response $response */ $response; diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index e406bd8e..544c0e4f 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -6,6 +6,7 @@ use attachments\helpers\attachment_content; use classes\attachment_store; use classes\attachments; use classes\authentication; +use classes\orders_input_normalizer; use classes\response; use classes\stripe; use JetBrains\PhpStorm\NoReturn; @@ -141,6 +142,15 @@ class ordersRoute $reg_1 = preg_replace('/\s+/', '', $reg_1); $reg_2 = preg_replace('/\s+/', '', $reg_2); $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 = [ 'customer_id' => (int)$data['customer_id'], 'department_id' => (int)$data['department_id'], @@ -153,7 +163,8 @@ class ordersRoute ...(!empty($data['lane']) ? ['lane' => (int)$data['lane']] : []), // Optional lane ...(!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 - '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 //$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 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 $order->objectChanged(); diff --git a/services/nginx/app/tests/Integration/DailyReports/DepartmentOutsideHoursStatisticsServiceIntegrationTest.php b/services/nginx/app/tests/Integration/DailyReports/DepartmentOutsideHoursStatisticsServiceIntegrationTest.php index 8e033d8e..d99ccd68 100644 --- a/services/nginx/app/tests/Integration/DailyReports/DepartmentOutsideHoursStatisticsServiceIntegrationTest.php +++ b/services/nginx/app/tests/Integration/DailyReports/DepartmentOutsideHoursStatisticsServiceIntegrationTest.php @@ -88,6 +88,7 @@ function department_outside_hours_prepare_tables(db $db): void reg_2 VARCHAR(64) NULL, reg_3 VARCHAR(64) NULL, created_at DATETIME NOT NULL, + include_in_invoice TINYINT(1) NULL, wash_id VARCHAR(255) NULL, deleted_at DATETIME NULL )' diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsOpenApiSpecTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsOpenApiSpecTest.php index 99c4e243..325b5731 100644 --- a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsOpenApiSpecTest.php @@ -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.'); } -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(); expect($content)->toContain('/departments/daily-reports/complaints:'); + expect($content)->toContain('/departments/daily-reports/complaints/customers:'); expect($content)->toContain('operationId: listDailyReportComplaints'); + expect($content)->toContain('operationId: searchDailyReportComplaintCustomers'); expect($content)->toContain('operationId: createDailyReportComplaint'); expect($content)->toContain('operationId: updateDailyReportComplaint'); expect($content)->toContain('operationId: deleteDailyReportComplaint'); expect($content)->toContain('DepartmentDailyReportComplaintCreateRequest:'); expect($content)->toContain('DepartmentDailyReportComplaintUpdateRequest:'); expect($content)->toContain('DepartmentDailyReportComplaint:'); + expect($content)->toContain('DepartmentDailyReportComplaintCustomerSearchResult:'); + expect($content)->toContain('DepartmentDailyReportComplaintCustomerSearchResponse:'); expect($content)->toContain('DepartmentDailyReportComplaintCollectionResponse:'); expect($content)->toContain('DepartmentDailyReportComplaintDeleteResponse:'); expect($content)->toContain('department_name:'); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php index 5321dfbe..ca00b3f0 100644 --- a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php @@ -1,15 +1,20 @@ 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('list_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("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("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("dailyReportComplaintsRepository()->addComplaint"); expect($content)->toContain("Complaint not found"); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php new file mode 100644 index 00000000..aa591ee1 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php @@ -0,0 +1,80 @@ + */ + public array $product_numbers = []; + + /** @var array */ + 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'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceOrderOverrideTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceOrderOverrideTest.php new file mode 100644 index 00000000..713a4464 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceOrderOverrideTest.php @@ -0,0 +1,88 @@ +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(); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php b/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php new file mode 100644 index 00000000..a5dce72b --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php @@ -0,0 +1,94 @@ +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, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersInputNormalizerTest.php b/services/nginx/app/tests/Unit/Orders/OrdersInputNormalizerTest.php new file mode 100644 index 00000000..4b420e95 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersInputNormalizerTest.php @@ -0,0 +1,28 @@ +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'); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRouteSettingsUpdateWiringTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRouteSettingsUpdateWiringTest.php new file mode 100644 index 00000000..974df68e --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersRouteSettingsUpdateWiringTest.php @@ -0,0 +1,24 @@ +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'])"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php index 0036aa04..79a1a787 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php @@ -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)->toContain("private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';"); 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_INTERNAL_SECRET')"); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php new file mode 100644 index 00000000..99005b67 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php @@ -0,0 +1,20 @@ +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()));"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php index f1732c23..b36e9923 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php @@ -15,6 +15,8 @@ function with_edge_gateway_server_state(array $server, callable $callback): void { $originalServer = $_SERVER; $originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL'); + $originalBrokerPublicUrl = getenv('EDGE_BROKER_PUBLIC_URL'); + $originalBrokerPublicPort = getenv('EDGE_BROKER_PUBLIC_PORT'); $_SERVER = $server; @@ -27,6 +29,16 @@ function with_edge_gateway_server_state(array $server, callable $callback): void } else { 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'); }); }); + +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'); + }); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php index 75db8b12..f9d2e755 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php @@ -15,7 +15,7 @@ it('registers the edge gateway management REST endpoints', function (): void { 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')); 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/claim'"); 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/shell/auth'"); expect($route)->toContain("'/edge-agent/internal/shell-sessions/{id}/close'"); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php index 21dc7872..d2397cb1 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php @@ -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')); expect($bootstrapContent)->toContain('last_heartbeat_at DATETIME NULL'); + expect($bootstrapContent)->toContain('command_job_id INT NULL'); expect($bootstrapContent)->toContain('channel INT NOT NULL DEFAULT 0'); expect($bootstrapContent)->toContain('transcript_text LONGTEXT NULL'); expect($bootstrapContent)->toContain('context_json JSON NULL');