feat(M08-C): 补保洁与设备运营
This commit is contained in:
@@ -143,6 +143,7 @@ export class DeviceControlService {
|
||||
this.assertWriteScope(context.access, context.storeId);
|
||||
const device = await this.resolveControlBox(context);
|
||||
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
||||
await this.auditCommand(context, device, commandType, orderId);
|
||||
return this.commands.issue({
|
||||
tenantId: context.tenantId,
|
||||
assetId: String(device.id),
|
||||
@@ -166,6 +167,7 @@ export class DeviceControlService {
|
||||
this.assertWriteScope(context.access, context.storeId);
|
||||
const device = await this.resolveSmartSocket(context);
|
||||
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
||||
await this.auditCommand(context, device, commandType, orderId);
|
||||
return this.commands.issue({
|
||||
tenantId: context.tenantId,
|
||||
assetId: String(device.id),
|
||||
@@ -217,6 +219,37 @@ export class DeviceControlService {
|
||||
}
|
||||
}
|
||||
|
||||
private async auditCommand(
|
||||
context: CommandContext,
|
||||
device: DeviceRow,
|
||||
commandType: string,
|
||||
orderId?: string | null
|
||||
) {
|
||||
await this.pool.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, ?, ?, 'DEVICE_COMMAND_REQUESTED', 'DEVICE', ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
context.tenantId,
|
||||
context.actorType ?? 'SYSTEM',
|
||||
context.actorId ?? null,
|
||||
device.id,
|
||||
context.traceId,
|
||||
(context.ip ?? '').slice(0, 64),
|
||||
(context.userAgent ?? '').slice(0, 255),
|
||||
JSON.stringify({
|
||||
commandType,
|
||||
storeId: context.storeId,
|
||||
roomId: context.roomId,
|
||||
orderId: orderId ?? context.orderId ?? null,
|
||||
deviceId: device.deviceId,
|
||||
deviceType: device.deviceType
|
||||
})
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private isManager(access: AccessProfile) {
|
||||
return access.roles.some((role) =>
|
||||
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
|
||||
@@ -230,6 +263,10 @@ export interface CommandContext {
|
||||
roomId: string;
|
||||
orderId?: string | null;
|
||||
traceId: string;
|
||||
actorType?: 'CUSTOMER' | 'STAFF' | 'ADMIN' | 'SYSTEM';
|
||||
actorId?: string | null;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
access: AccessProfile;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export class OrderDeviceAutomationService {
|
||||
roomId,
|
||||
orderId: order.id,
|
||||
traceId,
|
||||
actorType: 'SYSTEM' as const,
|
||||
access: systemDeviceAccess()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,29 +164,39 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/tasks', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = managerTaskListSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listManage({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/admin-api/cleaning/tasks',
|
||||
'/app-api/management/cleaning/tasks'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = managerTaskListSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listManage({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/admin-api/cleaning/statistics', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = statisticsSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/admin-api/cleaning/statistics',
|
||||
'/app-api/management/cleaning/statistics'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = statisticsSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
@@ -385,56 +395,71 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/complete', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = completeSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.complete({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/admin-api/cleaning/tasks/:taskId/complete',
|
||||
'/app-api/management/cleaning/tasks/:taskId/complete'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = completeSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.complete({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/reject', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = rejectSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.reject({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
reason: body.data.reason
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/admin-api/cleaning/tasks/:taskId/reject',
|
||||
'/app-api/management/cleaning/tasks/:taskId/reject'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = rejectSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.reject({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
reason: body.data.reason
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/exempt', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = exemptSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.exempt({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/admin-api/cleaning/tasks/:taskId/exempt',
|
||||
'/app-api/management/cleaning/tasks/:taskId/exempt'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = exemptSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.exempt({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
|
||||
@@ -99,33 +99,33 @@ export interface DeviceControlRouteOptions {
|
||||
export async function registerDeviceControlRoutes(
|
||||
app: FastifyInstance, options: DeviceControlRouteOptions
|
||||
) {
|
||||
register('/admin-api/device-control/power', powerSchema,
|
||||
registerManagement('/power', powerSchema,
|
||||
(context, body) => options.service.controlPower(context, body));
|
||||
register('/admin-api/device-control/door', doorSchema,
|
||||
registerManagement('/door', doorSchema,
|
||||
(context, body) => options.service.controlDoor(context, body));
|
||||
register('/admin-api/device-control/tts', ttsSchema,
|
||||
registerManagement('/tts', ttsSchema,
|
||||
(context, body) => options.service.playTts(context, body));
|
||||
register('/admin-api/device-control/tts/stop', contextSchema,
|
||||
registerManagement('/tts/stop', contextSchema,
|
||||
(context) => options.service.stopTts(context));
|
||||
register('/admin-api/device-control/led', minuteSchema,
|
||||
registerManagement('/led', minuteSchema,
|
||||
(context, body) => options.service.controlLed(context, body.minute));
|
||||
register('/admin-api/device-control/task/start', taskSchema,
|
||||
registerManagement('/task/start', taskSchema,
|
||||
(context, body) => options.service.startTask(context, body));
|
||||
register('/admin-api/device-control/task/extend', extendSchema,
|
||||
registerManagement('/task/extend', extendSchema,
|
||||
(context, body) => options.service.extendTask(context, body.addminute));
|
||||
register('/admin-api/device-control/task/cancel', contextSchema,
|
||||
registerManagement('/task/cancel', contextSchema,
|
||||
(context) => options.service.cancelTask(context));
|
||||
register('/admin-api/device-control/sub-lock/pair', pairSchema,
|
||||
registerManagement('/sub-lock/pair', pairSchema,
|
||||
(context, body) => options.service.pairSubLock(context, body.timeout));
|
||||
register('/admin-api/device-control/sub-lock/action', subLockSchema,
|
||||
registerManagement('/sub-lock/action', subLockSchema,
|
||||
(context, body) => options.service.controlSubLock(context, body));
|
||||
register('/admin-api/device-control/socket/read', socketReadSchema,
|
||||
registerManagement('/socket/read', socketReadSchema,
|
||||
(context, body) => options.service.readSmartSocket(context, body.target));
|
||||
register('/admin-api/device-control/socket/switch', socketSwitchSchema,
|
||||
registerManagement('/socket/switch', socketSwitchSchema,
|
||||
(context, body) => options.service.switchSmartSocket(context, body));
|
||||
register('/admin-api/device-control/socket/task', socketTaskSchema,
|
||||
registerManagement('/socket/task', socketTaskSchema,
|
||||
(context, body) => options.service.scheduleSmartSocket(context, body));
|
||||
register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema,
|
||||
registerManagement('/socket/task/clear', socketClearTaskSchema,
|
||||
(context, body) => options.service.clearSmartSocketTask(context, body.taskNum));
|
||||
|
||||
app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
|
||||
@@ -160,6 +160,10 @@ export async function registerDeviceControlRoutes(
|
||||
roomId: order.roomId,
|
||||
orderId: order.orderId,
|
||||
traceId: request.traceId,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: auth.session.user.id,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '',
|
||||
access: {
|
||||
roles: ['CUSTOMER'],
|
||||
capabilities: ['device.write'],
|
||||
@@ -216,6 +220,15 @@ export async function registerDeviceControlRoutes(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerManagement<T extends z.ZodTypeAny>(
|
||||
suffix: string,
|
||||
schema: T,
|
||||
handler: (context: CommandContext, body: z.infer<T>) => Promise<unknown>
|
||||
) {
|
||||
register(`/admin-api/device-control${suffix}`, schema, handler);
|
||||
register(`/app-api/management/device-control${suffix}`, schema, handler);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireContext(
|
||||
@@ -235,12 +248,19 @@ async function requireContext(
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
const actorType: CommandContext['actorType'] = access.roles.some((role) =>
|
||||
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
|
||||
) ? 'ADMIN' : 'STAFF';
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
storeId: input.storeId,
|
||||
roomId: input.roomId,
|
||||
orderId: input.orderId,
|
||||
traceId: request.traceId,
|
||||
actorType,
|
||||
actorId: auth.session.user.id,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '',
|
||||
access
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,16 +64,18 @@ export interface DeviceRouteOptions {
|
||||
}
|
||||
|
||||
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
|
||||
app.get('/admin-api/devices', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of ['/admin-api/devices', '/app-api/management/devices']) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
app.post('/admin-api/devices', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = assetSchema.safeParse(request.body);
|
||||
@@ -83,16 +85,18 @@ export async function registerDeviceRoutes(app: FastifyInstance, options: Device
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.get('/admin-api/device-topology', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getTopology(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of ['/admin-api/device-topology', '/app-api/management/device-topology']) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getTopology(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
app.post('/admin-api/device-channels', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = channelSchema.safeParse(request.body);
|
||||
|
||||
@@ -324,6 +324,15 @@ assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const appManageList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(appManageList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listManage');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
|
||||
const managerStats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/statistics?from=2026-06-01&to=2026-07-01&storeId=11&cleanerUserId=31',
|
||||
@@ -341,6 +350,15 @@ assert.equal(managerStats.json().data.members[0].rejectedTaskCount, 1);
|
||||
assert.equal(managerStats.json().data.trend[0].exempted, 1);
|
||||
assert.equal(managerStats.json().data.trend[0].paidSettlementCents, 600);
|
||||
|
||||
const appManagerStats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/cleaning/statistics?storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(appManagerStats.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'managerStatistics');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
|
||||
const claim = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/claim',
|
||||
@@ -410,6 +428,16 @@ const complete = await app.inject({
|
||||
assert.equal(complete.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'complete');
|
||||
|
||||
const appComplete = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/cleaning/tasks/101/complete',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'miniapp approval' }
|
||||
});
|
||||
assert.equal(appComplete.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'complete');
|
||||
assert.equal(calls.at(-1)[1].note, 'miniapp approval');
|
||||
|
||||
const reject = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/reject',
|
||||
@@ -420,6 +448,16 @@ assert.equal(reject.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reject');
|
||||
assert.equal(calls.at(-1)[1].reason, 'photo is unclear');
|
||||
|
||||
const appReject = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/cleaning/tasks/101/reject',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { reason: 'miniapp asks for rework' }
|
||||
});
|
||||
assert.equal(appReject.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reject');
|
||||
assert.equal(calls.at(-1)[1].reason, 'miniapp asks for rework');
|
||||
|
||||
const exempt = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/exempt',
|
||||
@@ -431,6 +469,16 @@ assert.equal(exempt.json().data.status, 'EXEMPT');
|
||||
assert.equal(calls.at(-1)[0], 'exempt');
|
||||
assert.equal(calls.at(-1)[1].note, 'no cleaning required');
|
||||
|
||||
const appExempt = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/cleaning/tasks/101/exempt',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'miniapp exemption' }
|
||||
});
|
||||
assert.equal(appExempt.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'exempt');
|
||||
assert.equal(calls.at(-1)[1].note, 'miniapp exemption');
|
||||
|
||||
const members = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks/101/members',
|
||||
|
||||
@@ -12,6 +12,7 @@ const managerAccess = {
|
||||
storeIds: ['11']
|
||||
};
|
||||
const issued = [];
|
||||
const audits = [];
|
||||
const service = new DeviceControlService({
|
||||
async execute(sql) {
|
||||
if (sql.includes('FROM qipai_devices')) {
|
||||
@@ -26,6 +27,7 @@ const service = new DeviceControlService({
|
||||
deviceType: 'CONTROL_BOX', status: 'ONLINE'
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('DEVICE_COMMAND_REQUESTED')) audits.push(sql);
|
||||
return [[], []];
|
||||
}
|
||||
}, {
|
||||
@@ -37,6 +39,7 @@ const service = new DeviceControlService({
|
||||
});
|
||||
const context = {
|
||||
tenantId: '7', storeId: '11', roomId: '31', traceId: 'trace',
|
||||
actorType: 'ADMIN', actorId: '22', ip: '127.0.0.1', userAgent: 'test',
|
||||
access: managerAccess
|
||||
};
|
||||
|
||||
@@ -75,6 +78,8 @@ assert.equal(issued.at(-1).payload.action, 'localtask');
|
||||
assert.equal(issued.at(-1).payload.switch, 'on');
|
||||
await service.clearSmartSocketTask(context, 1);
|
||||
assert.equal(issued.at(-1).payload.action, 'clearTask');
|
||||
assert.equal(audits.length, 14);
|
||||
assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.controlSubLock(context, {
|
||||
@@ -160,6 +165,25 @@ const response = await app.inject({
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(routed.slot1, 'on');
|
||||
const appPowerResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/device-control/power',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', slotall: 'off' }
|
||||
});
|
||||
assert.equal(appPowerResponse.statusCode, 200);
|
||||
assert.equal(routed.slotall, 'off');
|
||||
const appDoorResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/device-control/door',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', order: 'open', holdopen: 0, delayTime: 4 }
|
||||
});
|
||||
assert.equal(appDoorResponse.statusCode, 200);
|
||||
assert.equal(routed.order, 'open');
|
||||
assert.equal(routedContext.storeId, '11');
|
||||
assert.equal(routedContext.actorType, 'ADMIN');
|
||||
assert.equal(routedContext.actorId, '22');
|
||||
const socketResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/socket/switch',
|
||||
|
||||
@@ -69,6 +69,20 @@ const app = await buildApp({
|
||||
}
|
||||
}
|
||||
});
|
||||
const appAssets = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/devices?storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(appAssets.statusCode, 200);
|
||||
assert.deepEqual(appAssets.json().data, []);
|
||||
|
||||
const appTopology = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/device-topology?storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(appTopology.statusCode, 200);
|
||||
assert.deepEqual(appTopology.json().data.openAlerts, []);
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/admin-api/devices',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
|
||||
@@ -37,6 +37,8 @@ import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
|
||||
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
|
||||
import { DeviceRepository } from '../dist/devices/device-repository.js';
|
||||
import { IotMessageService } from '../dist/devices/iot-message-service.js';
|
||||
import { DeviceCommandService } from '../dist/devices/device-command-service.js';
|
||||
import { DeviceControlService } from '../dist/devices/device-control-service.js';
|
||||
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
@@ -1724,6 +1726,44 @@ async function assertDeviceTopology(pool, context) {
|
||||
signalStrength: 22, firmwareVersion: '1.0.1',
|
||||
snapshot: { slot1: true, door: 'closed' }
|
||||
});
|
||||
const published = [];
|
||||
const control = new DeviceControlService(
|
||||
pool,
|
||||
new DeviceCommandService(new IotMessageService(pool), {
|
||||
async publishDeviceCommand(deviceId, payload) {
|
||||
published.push({ deviceId, payload: JSON.parse(payload) });
|
||||
}
|
||||
})
|
||||
);
|
||||
const doorResult = await control.controlDoor({
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
roomId,
|
||||
traceId: 'm08c-manager-door',
|
||||
actorType: 'ADMIN',
|
||||
actorId: adminId,
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'M08-C manager miniapp',
|
||||
access
|
||||
}, { order: 'open', holdopen: 0, delayTime: 4 });
|
||||
assert.equal(doorResult.status, 'PUBLISHED');
|
||||
assert.equal(published[0].deviceId, 'M06B_BOX_001');
|
||||
assert.equal(published[0].payload.action, 'Crldoor');
|
||||
const [commandAuditRows] = await pool.query(
|
||||
`SELECT actor_type AS actorType, actor_id AS actorId, action,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.commandType')) AS commandType,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.roomId')) AS roomId
|
||||
FROM qipai_audit_logs
|
||||
WHERE tenant_id = ? AND trace_id = 'm08c-manager-door'`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.deepEqual(commandAuditRows, [{
|
||||
actorType: 'ADMIN',
|
||||
actorId: Number(adminId),
|
||||
action: 'DEVICE_COMMAND_REQUESTED',
|
||||
commandType: 'Crldoor',
|
||||
roomId
|
||||
}]);
|
||||
await repository.addMaintenance(actor, {
|
||||
assetId: controlBox.assetId, storeId, roomId,
|
||||
recordType: 'INSPECTION', status: 'OPEN', description: 'M06-B inspection'
|
||||
@@ -2051,6 +2091,8 @@ try {
|
||||
'Sub-1G parent-child topology',
|
||||
'device status snapshots and maintenance state'
|
||||
,
|
||||
'manager device command actor audit attribution'
|
||||
,
|
||||
'13-digit IoT command state transition',
|
||||
'QoS 1 duplicate event receive count',
|
||||
'ACK correlation without duplicate side effects',
|
||||
|
||||
Reference in New Issue
Block a user