Add unit tests for invoicing, orders normalization, gateway commands, and department complaints. Update schema bootstraps and improve agent command execution logic.
This commit is contained in:
+516
-11
@@ -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:
|
||||
|
||||
Vendored
+212
-36
@@ -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 });
|
||||
}
|
||||
|
||||
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 || "") });
|
||||
});
|
||||
|
||||
sessions.set(sessionId, proc);
|
||||
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,13 +511,16 @@ 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) {
|
||||
if (message.type === "COMMAND") {
|
||||
socket.send(JSON.stringify({
|
||||
type: "COMMAND_RESULT",
|
||||
commandId: message.commandId,
|
||||
@@ -380,6 +528,7 @@ export function connectBroker(
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return socket;
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"node-pty": "^1.1.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-pty": "^1.1.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 resultPosts = [];
|
||||
let polledCommandDelivered = false;
|
||||
|
||||
const fakeFetch = async (url, options = {}) => {
|
||||
const body = options.body ? JSON.parse(options.body) : {};
|
||||
|
||||
if (String(url).endsWith("/heartbeat")) {
|
||||
heartbeats.push({
|
||||
url,
|
||||
body: JSON.parse(options.body ?? "{}"),
|
||||
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return (array)($response['payload'] ?? $response);
|
||||
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;
|
||||
}
|
||||
|
||||
if ($commandType === 'RUN_UPDATE') {
|
||||
$this->finalizeLinkedUpdateJob((int)$job->id, $ok, $payload, $errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseCommandJobToQueue(edge_gateway_command_jobs_o $job): void
|
||||
{
|
||||
$job->response_json->set([]);
|
||||
$job->error_message->set(null);
|
||||
$job->completed_at->set(null);
|
||||
$job->status->set('PENDING');
|
||||
}
|
||||
|
||||
private function markCommandJobDispatching(edge_gateway_command_jobs_o $job): void
|
||||
{
|
||||
$job->response_json->set([]);
|
||||
$job->error_message->set(null);
|
||||
$job->completed_at->set(null);
|
||||
$job->status->set('DISPATCHING');
|
||||
}
|
||||
|
||||
private function shouldFallbackToQueuedDelivery(\Throwable $throwable): bool
|
||||
{
|
||||
if ($throwable instanceof edge_broker_transport_exception) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($throwable instanceof edge_broker_http_exception) {
|
||||
return $throwable->statusCode() === 503;
|
||||
}
|
||||
|
||||
$message = $throwable->getMessage();
|
||||
return str_contains($message, 'Gateway agent is offline')
|
||||
|| str_contains($message, 'Could not resolve host:')
|
||||
|| str_contains($message, 'Failed to connect')
|
||||
|| str_contains($message, 'Connection refused');
|
||||
}
|
||||
|
||||
private function markLinkedUpdateJobStarted(int $commandJobId): void
|
||||
{
|
||||
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
|
||||
if ($updateJob === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($updateJob->started_at->value() === null) {
|
||||
$updateJob->started_at->set($this->now());
|
||||
}
|
||||
}
|
||||
|
||||
private function finalizeLinkedUpdateJob(int $commandJobId, bool $ok, array $payload, ?string $errorMessage): void
|
||||
{
|
||||
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
|
||||
if ($updateJob === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($updateJob->started_at->value() === null) {
|
||||
$updateJob->started_at->set($this->now());
|
||||
}
|
||||
|
||||
$updateJob->status->set($ok ? 'COMPLETED' : 'FAILED');
|
||||
$updateJob->completed_at->set($this->now());
|
||||
$updateJob->result_json->set($ok
|
||||
? $payload
|
||||
: ['error' => $errorMessage ?: 'Edge gateway update failed']);
|
||||
}
|
||||
|
||||
private function findLinkedUpdateJobByCommandId(int $commandJobId): ?edge_gateway_update_jobs_o
|
||||
{
|
||||
$rows = (new edge_gateway_update_jobs_o())->getFieldsWhere([
|
||||
'command_job_id' => $commandJobId,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (new edge_gateway_update_jobs_o())->select((int)$rows[0]['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,7 @@ class edge_gateway_schema_bootstrap
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs (
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class orders_input_normalizer
|
||||
{
|
||||
public static function normalizeCreatedAt(mixed $value): string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
throw new InvalidArgumentException('created_at must be a string');
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
throw new InvalidArgumentException('created_at cannot be empty');
|
||||
}
|
||||
|
||||
foreach (['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i'] as $format) {
|
||||
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
|
||||
if ($parsed instanceof DateTimeImmutable && $parsed->format($format) === $trimmed) {
|
||||
return $parsed->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('created_at must be a valid datetime');
|
||||
}
|
||||
|
||||
public static function normalizeIncludeInInvoice(mixed $value): ?bool
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
if ($value === 1) {
|
||||
return true;
|
||||
}
|
||||
if ($value === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$normalized = strtolower(trim($value));
|
||||
return match ($normalized) {
|
||||
'', 'null', 'use_department' => null,
|
||||
'1', 'true', 'include', 'included', 'yes' => true,
|
||||
'0', 'false', 'exclude', 'excluded', 'no' => false,
|
||||
default => throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude'),
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for orders metadata.
|
||||
*/
|
||||
class orders_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"ALTER TABLE orders
|
||||
ADD COLUMN IF NOT EXISTS include_in_invoice TINYINT(1) NULL DEFAULT NULL
|
||||
AFTER created_at"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,52 @@ class economicCustomers extends economic_m
|
||||
if ($customer_number === 0) {
|
||||
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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
@@ -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
|
||||
)'
|
||||
|
||||
+5
-1
@@ -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:');
|
||||
|
||||
+6
-1
@@ -1,15 +1,20 @@
|
||||
<?php
|
||||
|
||||
it('wires complaint create, list, edit, and delete routes with validation and parsing', function (): void {
|
||||
it('wires complaint create, lookup, list, edit, and delete routes with validation and parsing', function (): void {
|
||||
$content = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php'));
|
||||
|
||||
expect($content)->toContain('/departments/daily-reports/complaints');
|
||||
expect($content)->toContain('/departments/daily-reports/complaints/customers');
|
||||
expect($content)->toContain("requirePermission('create_department_daily_report_complaints')");
|
||||
expect($content)->toContain("requirePermission('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");
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
app_require('modules/economic/economic_m.php');
|
||||
app_require('modules/economic/customers/economicCustomers.php');
|
||||
|
||||
use customers\economicCustomers;
|
||||
|
||||
class economicCustomersDiscountFallbackTestDouble extends economicCustomers
|
||||
{
|
||||
/** @var array<int, int|string> */
|
||||
public array $product_numbers = [];
|
||||
|
||||
/** @var array<int, object|\RuntimeException> */
|
||||
public array $discount_responses = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object
|
||||
{
|
||||
return (object)[
|
||||
'collection' => array_map(
|
||||
fn (int|string $product_number): object => (object)[
|
||||
'product' => (object)[
|
||||
'productNumber' => (string)$product_number,
|
||||
],
|
||||
],
|
||||
$this->product_numbers
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function getCustomerProductDiscount(int $customer_number, int $product_id)
|
||||
{
|
||||
$response = $this->discount_responses[$product_id] ?? (object)[
|
||||
'discountPercentage' => 0,
|
||||
];
|
||||
|
||||
if ($response instanceof \RuntimeException) {
|
||||
throw $response;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
it('falls back to a later customer template product when earlier probes fail on missing currency prices', function (): void {
|
||||
$economic = new economicCustomersDiscountFallbackTestDouble();
|
||||
$economic->product_numbers = [1, 5, 9];
|
||||
$economic->discount_responses = [
|
||||
1 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
|
||||
5 => (object)['discountPercentage' => 12],
|
||||
];
|
||||
|
||||
expect($economic->getCustomerDiscountPercentage(23152645))->toBe(12);
|
||||
});
|
||||
|
||||
it('returns zero when every customer template lookup fails due to missing currency prices', function (): void {
|
||||
$economic = new economicCustomersDiscountFallbackTestDouble();
|
||||
$economic->product_numbers = [1, 2, 3];
|
||||
$economic->discount_responses = [
|
||||
1 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
|
||||
2 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
|
||||
3 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
|
||||
];
|
||||
|
||||
expect($economic->getCustomerDiscountPercentage(23152645))->toBe(0);
|
||||
});
|
||||
|
||||
it('rethrows unrelated discount lookup failures', function (): void {
|
||||
$economic = new economicCustomersDiscountFallbackTestDouble();
|
||||
$economic->product_numbers = [1];
|
||||
$economic->discount_responses = [
|
||||
1 => new RuntimeException('HTTP 503 upstream unavailable'),
|
||||
];
|
||||
|
||||
expect(fn () => $economic->getCustomerDiscountPercentage(23152645))
|
||||
->toThrow(RuntimeException::class, 'HTTP 503 upstream unavailable');
|
||||
});
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/economic_v2_versioning_service.php');
|
||||
app_require('classes/economic_v2_distribution_service.php');
|
||||
|
||||
use classes\economic_v2_distribution_service;
|
||||
use classes\economic_v2_versioning_service;
|
||||
|
||||
if (!class_exists('EconomicV2DistributionServiceOrderOverrideVersioningDouble')) {
|
||||
class EconomicV2DistributionServiceOrderOverrideVersioningDouble extends economic_v2_versioning_service
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function runBestEffortBackfill(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('EconomicV2DistributionServiceOrderOverrideDouble')) {
|
||||
class EconomicV2DistributionServiceOrderOverrideDouble extends economic_v2_distribution_service
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new EconomicV2DistributionServiceOrderOverrideVersioningDouble());
|
||||
}
|
||||
|
||||
public function exposeIsOrderEligible(array $order): bool
|
||||
{
|
||||
return $this->isOrderEligible($order);
|
||||
}
|
||||
|
||||
protected function isDepartmentEligible(int $department_id): bool
|
||||
{
|
||||
return $department_id === 12;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('inherits department eligibility when no order override is set', function (): void {
|
||||
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
|
||||
|
||||
expect($service->exposeIsOrderEligible([
|
||||
'department_id' => 12,
|
||||
'include_in_invoice' => null,
|
||||
]))->toBeTrue();
|
||||
|
||||
expect($service->exposeIsOrderEligible([
|
||||
'department_id' => 13,
|
||||
'include_in_invoice' => null,
|
||||
]))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows an order-level include override to overrule department exclusion', function (): void {
|
||||
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
|
||||
|
||||
expect($service->exposeIsOrderEligible([
|
||||
'department_id' => 13,
|
||||
'include_in_invoice' => '1',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows an order-level exclude override to overrule department inclusion', function (): void {
|
||||
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
|
||||
|
||||
expect($service->exposeIsOrderEligible([
|
||||
'department_id' => 12,
|
||||
'include_in_invoice' => '0',
|
||||
]))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/object_property.php');
|
||||
app_require('objects/orders_o.php');
|
||||
|
||||
use classes\object_property;
|
||||
use objects\orders_o;
|
||||
|
||||
if (!class_exists('OrdersIncludeInInvoiceOverrideOrderDouble')) {
|
||||
class OrdersIncludeInInvoiceOverrideOrderDouble extends orders_o
|
||||
{
|
||||
public bool $departmentIncluded = true;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->id = -1;
|
||||
$this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true);
|
||||
$this->cashier_id = new object_property('orders', -1, 'cashier_id', 'int', true);
|
||||
$this->reference = new object_property('orders', -1, 'reference', 'string', false);
|
||||
$this->notes = new object_property('orders', -1, 'notes', 'string', false);
|
||||
$this->department_id = new object_property('orders', -1, 'department_id', 'int', true);
|
||||
$this->reg_1 = new object_property('orders', -1, 'reg_1', 'string', false);
|
||||
$this->reg_2 = new object_property('orders', -1, 'reg_2', 'string', false);
|
||||
$this->reg_3 = new object_property('orders', -1, 'reg_3', 'string', false);
|
||||
$this->created_at = new object_property('orders', -1, 'created_at', 'timestamp', false);
|
||||
$this->include_in_invoice = new object_property('orders', -1, 'include_in_invoice', 'bool', false);
|
||||
$this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property('orders', -1, 'deleted_at', 'timestamp', false);
|
||||
$this->invoice_collection_id = new object_property('orders', -1, 'invoice_collection_id', 'int', false);
|
||||
$this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false);
|
||||
$this->wash_id = new object_property('orders', -1, 'wash_id', 'string', false);
|
||||
$this->lane = new object_property('orders', -1, 'lane', 'string', false);
|
||||
$this->po = new object_property('orders', -1, 'po', 'string', false);
|
||||
$this->using_hand_held = new object_property('orders', -1, 'using_hand_held', 'bool', false);
|
||||
$this->temporary_net_amount = 125.5;
|
||||
|
||||
$this->customer_id->set(1234567);
|
||||
$this->cashier_id->set(42);
|
||||
$this->department_id->set(12);
|
||||
$this->created_at->set('2026-04-09 12:34:56');
|
||||
$this->reference->set('Ref');
|
||||
}
|
||||
|
||||
protected function resolveDepartmentIncludedInInvoicing(): bool
|
||||
{
|
||||
return $this->departmentIncluded;
|
||||
}
|
||||
|
||||
public function isPendingHandheld(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('lets the order-level override include an otherwise excluded order', function (): void {
|
||||
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
|
||||
$order->departmentIncluded = false;
|
||||
$order->include_in_invoice->set(true);
|
||||
|
||||
expect($order->getIncludeInInvoiceOverride())->toBeTrue();
|
||||
expect($order->isIncludedInInvoicing())->toBeTrue();
|
||||
});
|
||||
|
||||
it('lets the order-level override exclude an otherwise included order', function (): void {
|
||||
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
|
||||
$order->departmentIncluded = true;
|
||||
$order->include_in_invoice->set(false);
|
||||
|
||||
expect($order->getIncludeInInvoiceOverride())->toBeFalse();
|
||||
expect($order->isIncludedInInvoicing())->toBeFalse();
|
||||
});
|
||||
|
||||
it('falls back to the department invoicing rule when the override is null', function (): void {
|
||||
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
|
||||
$order->departmentIncluded = false;
|
||||
$order->include_in_invoice->set(null);
|
||||
|
||||
expect($order->getIncludeInInvoiceOverride())->toBeNull();
|
||||
expect($order->isIncludedInInvoicing())->toBeFalse();
|
||||
});
|
||||
|
||||
it('serializes both raw and effective include_in_invoice values', function (): void {
|
||||
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
|
||||
$order->departmentIncluded = true;
|
||||
$order->include_in_invoice->set(null);
|
||||
|
||||
expect($order->asArray(true, false))->toMatchArray([
|
||||
'include_in_invoice' => null,
|
||||
'include_in_invoice_effective' => true,
|
||||
'created_at' => '2026-04-09 12:34:56',
|
||||
'total_net_amount' => 125.5,
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/orders_input_normalizer.php');
|
||||
|
||||
use classes\orders_input_normalizer;
|
||||
|
||||
it('normalizes sql and datetime-local created_at values', function (): void {
|
||||
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09 12:34:56'))->toBe('2026-04-09 12:34:56');
|
||||
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09T12:34'))->toBe('2026-04-09 12:34:00');
|
||||
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09T12:34:56'))->toBe('2026-04-09 12:34:56');
|
||||
});
|
||||
|
||||
it('rejects invalid created_at values', function (): void {
|
||||
orders_input_normalizer::normalizeCreatedAt('2026/04/09 12:34');
|
||||
})->throws(InvalidArgumentException::class, 'created_at must be a valid datetime');
|
||||
|
||||
it('normalizes include_in_invoice tri-state inputs', function (): void {
|
||||
expect(orders_input_normalizer::normalizeIncludeInInvoice(null))->toBeNull();
|
||||
expect(orders_input_normalizer::normalizeIncludeInInvoice('use_department'))->toBeNull();
|
||||
expect(orders_input_normalizer::normalizeIncludeInInvoice('include'))->toBeTrue();
|
||||
expect(orders_input_normalizer::normalizeIncludeInInvoice('exclude'))->toBeFalse();
|
||||
expect(orders_input_normalizer::normalizeIncludeInInvoice(true))->toBeTrue();
|
||||
expect(orders_input_normalizer::normalizeIncludeInInvoice(false))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects invalid include_in_invoice values', function (): void {
|
||||
orders_input_normalizer::normalizeIncludeInInvoice('maybe');
|
||||
})->throws(InvalidArgumentException::class, 'include_in_invoice must be use_department, include, or exclude');
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
it('wires order create and update routes through the settings normalizers', function (): void {
|
||||
$routeFile = app_path('routes/ordersRoute.php');
|
||||
$content = file_get_contents($routeFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('use classes\orders_input_normalizer;');
|
||||
expect($content)->toContain("orders_input_normalizer::normalizeCreatedAt(\$data['created_at'] ?? date('Y-m-d H:i:s'))");
|
||||
expect($content)->toContain("array_key_exists('include_in_invoice', \$data)");
|
||||
expect($content)->toContain("orders_input_normalizer::normalizeIncludeInInvoice(\$data['include_in_invoice'])");
|
||||
expect($content)->toContain("\$order->include_in_invoice->set(");
|
||||
expect($content)->toContain("\$response->error(\$e->getMessage(), 400);");
|
||||
});
|
||||
|
||||
it('keeps line-item invoice filtering in the order net amount calculation', function (): void {
|
||||
$objectFile = app_path('objects/orders_o.php');
|
||||
$content = file_get_contents($objectFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain("'include_in_invoice' => \$this->getIncludeInInvoiceOverride()");
|
||||
expect($content)->toContain("'include_in_invoice_effective' => \$this->isIncludedInInvoicing()");
|
||||
expect($content)->toContain("if (!\$item['include_in_invoice'])");
|
||||
});
|
||||
@@ -6,6 +6,8 @@ it('uses the same default broker url and shared secret fallback as the docker st
|
||||
expect($source)->not->toBeFalse();
|
||||
expect($source)->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')");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
it('queues admin commands, exposes agent poll/result handlers, and keeps heartbeats from mutating discovery state', function (): void {
|
||||
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||
|
||||
expect($source)->not->toBeFalse();
|
||||
expect($source)->toContain('public function pollCommand');
|
||||
expect($source)->toContain('public function submitCommandResult');
|
||||
expect($source)->toContain('private function tryImmediateBrokerDispatch');
|
||||
expect($source)->toContain('private function waitForCommandResult');
|
||||
expect($source)->toContain('private function claimNextCommandJob');
|
||||
expect($source)->toContain("COALESCE(updated_at, created_at, requested_at) <= :stale_before");
|
||||
expect($source)->toContain("throw new Exception('Edge gateway command timed out');");
|
||||
expect($source)->toContain("throw new Exception('Gateway agent is offline');");
|
||||
expect($source)->toContain("\$gateway->discovery_status->set('PENDING');");
|
||||
expect($source)->toContain("\$gateway->discovery_status->set('READY');");
|
||||
expect($source)->toContain("\$gateway->discovery_status->set('FAILED');");
|
||||
expect($source)->toContain("\$updateJob->status->set(\$ok ? 'COMPLETED' : 'FAILED');");
|
||||
expect($source)->not->toContain("\$gateway->discovery_status->set((string)(\$payload['discovery_status'] ?? \$gateway->discovery_status->value()));");
|
||||
});
|
||||
@@ -15,6 +15,8 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
|
||||
{
|
||||
$originalServer = $_SERVER;
|
||||
$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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'");
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user