feat(M08-C): 补保洁与设备运营
This commit is contained in:
@@ -143,6 +143,7 @@ export class DeviceControlService {
|
|||||||
this.assertWriteScope(context.access, context.storeId);
|
this.assertWriteScope(context.access, context.storeId);
|
||||||
const device = await this.resolveControlBox(context);
|
const device = await this.resolveControlBox(context);
|
||||||
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
||||||
|
await this.auditCommand(context, device, commandType, orderId);
|
||||||
return this.commands.issue({
|
return this.commands.issue({
|
||||||
tenantId: context.tenantId,
|
tenantId: context.tenantId,
|
||||||
assetId: String(device.id),
|
assetId: String(device.id),
|
||||||
@@ -166,6 +167,7 @@ export class DeviceControlService {
|
|||||||
this.assertWriteScope(context.access, context.storeId);
|
this.assertWriteScope(context.access, context.storeId);
|
||||||
const device = await this.resolveSmartSocket(context);
|
const device = await this.resolveSmartSocket(context);
|
||||||
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
||||||
|
await this.auditCommand(context, device, commandType, orderId);
|
||||||
return this.commands.issue({
|
return this.commands.issue({
|
||||||
tenantId: context.tenantId,
|
tenantId: context.tenantId,
|
||||||
assetId: String(device.id),
|
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) {
|
private isManager(access: AccessProfile) {
|
||||||
return access.roles.some((role) =>
|
return access.roles.some((role) =>
|
||||||
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
|
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
|
||||||
@@ -230,6 +263,10 @@ export interface CommandContext {
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
orderId?: string | null;
|
orderId?: string | null;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
|
actorType?: 'CUSTOMER' | 'STAFF' | 'ADMIN' | 'SYSTEM';
|
||||||
|
actorId?: string | null;
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
access: AccessProfile;
|
access: AccessProfile;
|
||||||
expiresAt?: Date | null;
|
expiresAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export class OrderDeviceAutomationService {
|
|||||||
roomId,
|
roomId,
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
traceId,
|
traceId,
|
||||||
|
actorType: 'SYSTEM' as const,
|
||||||
access: systemDeviceAccess()
|
access: systemDeviceAccess()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,29 +164,39 @@ export async function registerCleaningRoutes(
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/admin-api/cleaning/tasks', async (request, reply) => {
|
for (const path of [
|
||||||
const actor = await requireActor(request, reply, options, 'read');
|
'/admin-api/cleaning/tasks',
|
||||||
if (!actor) return;
|
'/app-api/management/cleaning/tasks'
|
||||||
const query = managerTaskListSchema.safeParse(request.query);
|
]) {
|
||||||
if (!query.success) return invalid(reply, request.traceId);
|
app.get(path, async (request, reply) => {
|
||||||
return handle(reply, request.traceId, async () => ({
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
code: 0,
|
if (!actor) return;
|
||||||
data: await options.repository.listManage({ ...actor, ...query.data }),
|
const query = managerTaskListSchema.safeParse(request.query);
|
||||||
traceId: request.traceId
|
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) => {
|
for (const path of [
|
||||||
const actor = await requireActor(request, reply, options, 'read');
|
'/admin-api/cleaning/statistics',
|
||||||
if (!actor) return;
|
'/app-api/management/cleaning/statistics'
|
||||||
const query = statisticsSchema.safeParse(request.query);
|
]) {
|
||||||
if (!query.success) return invalid(reply, request.traceId);
|
app.get(path, async (request, reply) => {
|
||||||
return handle(reply, request.traceId, async () => ({
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
code: 0,
|
if (!actor) return;
|
||||||
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
|
const query = statisticsSchema.safeParse(request.query);
|
||||||
traceId: request.traceId
|
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) => {
|
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
|
||||||
const actor = await requireActor(request, reply, options, 'read');
|
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) => {
|
for (const path of [
|
||||||
const actor = await requireActor(request, reply, options, 'write');
|
'/admin-api/cleaning/tasks/:taskId/complete',
|
||||||
if (!actor) return;
|
'/app-api/management/cleaning/tasks/:taskId/complete'
|
||||||
const params = paramsSchema.safeParse(request.params);
|
]) {
|
||||||
const body = completeSchema.safeParse(request.body ?? {});
|
app.post(path, async (request, reply) => {
|
||||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
return handle(reply, request.traceId, async () => ({
|
if (!actor) return;
|
||||||
code: 0,
|
const params = paramsSchema.safeParse(request.params);
|
||||||
data: await options.repository.complete({
|
const body = completeSchema.safeParse(request.body ?? {});
|
||||||
...actor,
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
taskId: params.data.taskId,
|
return handle(reply, request.traceId, async () => ({
|
||||||
note: body.data.note
|
code: 0,
|
||||||
}),
|
data: await options.repository.complete({
|
||||||
traceId: request.traceId
|
...actor,
|
||||||
}));
|
taskId: params.data.taskId,
|
||||||
});
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/admin-api/cleaning/tasks/:taskId/reject', async (request, reply) => {
|
for (const path of [
|
||||||
const actor = await requireActor(request, reply, options, 'write');
|
'/admin-api/cleaning/tasks/:taskId/reject',
|
||||||
if (!actor) return;
|
'/app-api/management/cleaning/tasks/:taskId/reject'
|
||||||
const params = paramsSchema.safeParse(request.params);
|
]) {
|
||||||
const body = rejectSchema.safeParse(request.body ?? {});
|
app.post(path, async (request, reply) => {
|
||||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
return handle(reply, request.traceId, async () => ({
|
if (!actor) return;
|
||||||
code: 0,
|
const params = paramsSchema.safeParse(request.params);
|
||||||
data: await options.repository.reject({
|
const body = rejectSchema.safeParse(request.body ?? {});
|
||||||
...actor,
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
taskId: params.data.taskId,
|
return handle(reply, request.traceId, async () => ({
|
||||||
reason: body.data.reason
|
code: 0,
|
||||||
}),
|
data: await options.repository.reject({
|
||||||
traceId: request.traceId
|
...actor,
|
||||||
}));
|
taskId: params.data.taskId,
|
||||||
});
|
reason: body.data.reason
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/admin-api/cleaning/tasks/:taskId/exempt', async (request, reply) => {
|
for (const path of [
|
||||||
const actor = await requireActor(request, reply, options, 'write');
|
'/admin-api/cleaning/tasks/:taskId/exempt',
|
||||||
if (!actor) return;
|
'/app-api/management/cleaning/tasks/:taskId/exempt'
|
||||||
const params = paramsSchema.safeParse(request.params);
|
]) {
|
||||||
const body = exemptSchema.safeParse(request.body ?? {});
|
app.post(path, async (request, reply) => {
|
||||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
return handle(reply, request.traceId, async () => ({
|
if (!actor) return;
|
||||||
code: 0,
|
const params = paramsSchema.safeParse(request.params);
|
||||||
data: await options.repository.exempt({
|
const body = exemptSchema.safeParse(request.body ?? {});
|
||||||
...actor,
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
taskId: params.data.taskId,
|
return handle(reply, request.traceId, async () => ({
|
||||||
note: body.data.note
|
code: 0,
|
||||||
}),
|
data: await options.repository.exempt({
|
||||||
traceId: request.traceId
|
...actor,
|
||||||
}));
|
taskId: params.data.taskId,
|
||||||
});
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
|
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
|
||||||
const actor = await requireActor(request, reply, options, 'write');
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
|||||||
@@ -99,33 +99,33 @@ export interface DeviceControlRouteOptions {
|
|||||||
export async function registerDeviceControlRoutes(
|
export async function registerDeviceControlRoutes(
|
||||||
app: FastifyInstance, options: DeviceControlRouteOptions
|
app: FastifyInstance, options: DeviceControlRouteOptions
|
||||||
) {
|
) {
|
||||||
register('/admin-api/device-control/power', powerSchema,
|
registerManagement('/power', powerSchema,
|
||||||
(context, body) => options.service.controlPower(context, body));
|
(context, body) => options.service.controlPower(context, body));
|
||||||
register('/admin-api/device-control/door', doorSchema,
|
registerManagement('/door', doorSchema,
|
||||||
(context, body) => options.service.controlDoor(context, body));
|
(context, body) => options.service.controlDoor(context, body));
|
||||||
register('/admin-api/device-control/tts', ttsSchema,
|
registerManagement('/tts', ttsSchema,
|
||||||
(context, body) => options.service.playTts(context, body));
|
(context, body) => options.service.playTts(context, body));
|
||||||
register('/admin-api/device-control/tts/stop', contextSchema,
|
registerManagement('/tts/stop', contextSchema,
|
||||||
(context) => options.service.stopTts(context));
|
(context) => options.service.stopTts(context));
|
||||||
register('/admin-api/device-control/led', minuteSchema,
|
registerManagement('/led', minuteSchema,
|
||||||
(context, body) => options.service.controlLed(context, body.minute));
|
(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));
|
(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));
|
(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));
|
(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));
|
(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));
|
(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));
|
(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));
|
(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));
|
(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));
|
(context, body) => options.service.clearSmartSocketTask(context, body.taskNum));
|
||||||
|
|
||||||
app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
|
app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
|
||||||
@@ -160,6 +160,10 @@ export async function registerDeviceControlRoutes(
|
|||||||
roomId: order.roomId,
|
roomId: order.roomId,
|
||||||
orderId: order.orderId,
|
orderId: order.orderId,
|
||||||
traceId: request.traceId,
|
traceId: request.traceId,
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: auth.session.user.id,
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? '',
|
||||||
access: {
|
access: {
|
||||||
roles: ['CUSTOMER'],
|
roles: ['CUSTOMER'],
|
||||||
capabilities: ['device.write'],
|
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(
|
async function requireContext(
|
||||||
@@ -235,12 +248,19 @@ async function requireContext(
|
|||||||
const access = await options.accessControl.getAccessProfile(
|
const access = await options.accessControl.getAccessProfile(
|
||||||
auth.session.tenantId, auth.session.user.id
|
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 {
|
return {
|
||||||
tenantId: auth.session.tenantId,
|
tenantId: auth.session.tenantId,
|
||||||
storeId: input.storeId,
|
storeId: input.storeId,
|
||||||
roomId: input.roomId,
|
roomId: input.roomId,
|
||||||
orderId: input.orderId,
|
orderId: input.orderId,
|
||||||
traceId: request.traceId,
|
traceId: request.traceId,
|
||||||
|
actorType,
|
||||||
|
actorId: auth.session.user.id,
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? '',
|
||||||
access
|
access
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,16 +64,18 @@ export interface DeviceRouteOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
|
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
|
||||||
app.get('/admin-api/devices', async (request, reply) => {
|
for (const path of ['/admin-api/devices', '/app-api/management/devices']) {
|
||||||
const actor = await requireDeviceOperator(request, reply, options);
|
app.get(path, async (request, reply) => {
|
||||||
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
const actor = await requireDeviceOperator(request, reply, options);
|
||||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
||||||
return mutate(reply, request.traceId, async () => ({
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
code: 0,
|
return mutate(reply, request.traceId, async () => ({
|
||||||
data: await options.repository.listAssets(actor, query.data.storeId),
|
code: 0,
|
||||||
traceId: request.traceId
|
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||||
}));
|
traceId: request.traceId
|
||||||
});
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
app.post('/admin-api/devices', async (request, reply) => {
|
app.post('/admin-api/devices', async (request, reply) => {
|
||||||
const actor = await requireDeviceOperator(request, reply, options);
|
const actor = await requireDeviceOperator(request, reply, options);
|
||||||
const body = assetSchema.safeParse(request.body);
|
const body = assetSchema.safeParse(request.body);
|
||||||
@@ -83,16 +85,18 @@ export async function registerDeviceRoutes(app: FastifyInstance, options: Device
|
|||||||
traceId: request.traceId
|
traceId: request.traceId
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
app.get('/admin-api/device-topology', async (request, reply) => {
|
for (const path of ['/admin-api/device-topology', '/app-api/management/device-topology']) {
|
||||||
const actor = await requireDeviceOperator(request, reply, options);
|
app.get(path, async (request, reply) => {
|
||||||
const query = z.object({ storeId: id }).safeParse(request.query);
|
const actor = await requireDeviceOperator(request, reply, options);
|
||||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
const query = z.object({ storeId: id }).safeParse(request.query);
|
||||||
return mutate(reply, request.traceId, async () => ({
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
code: 0,
|
return mutate(reply, request.traceId, async () => ({
|
||||||
data: await options.repository.getTopology(actor, query.data.storeId),
|
code: 0,
|
||||||
traceId: request.traceId
|
data: await options.repository.getTopology(actor, query.data.storeId),
|
||||||
}));
|
traceId: request.traceId
|
||||||
});
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
app.post('/admin-api/device-channels', async (request, reply) => {
|
app.post('/admin-api/device-channels', async (request, reply) => {
|
||||||
const actor = await requireDeviceOperator(request, reply, options);
|
const actor = await requireDeviceOperator(request, reply, options);
|
||||||
const body = channelSchema.safeParse(request.body);
|
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].storeId, '11');
|
||||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
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({
|
const managerStats = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/admin-api/cleaning/statistics?from=2026-06-01&to=2026-07-01&storeId=11&cleanerUserId=31',
|
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].exempted, 1);
|
||||||
assert.equal(managerStats.json().data.trend[0].paidSettlementCents, 600);
|
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({
|
const claim = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/app-api/cleaning/tasks/101/claim',
|
url: '/app-api/cleaning/tasks/101/claim',
|
||||||
@@ -410,6 +428,16 @@ const complete = await app.inject({
|
|||||||
assert.equal(complete.statusCode, 200);
|
assert.equal(complete.statusCode, 200);
|
||||||
assert.equal(calls.at(-1)[0], 'complete');
|
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({
|
const reject = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/admin-api/cleaning/tasks/101/reject',
|
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)[0], 'reject');
|
||||||
assert.equal(calls.at(-1)[1].reason, 'photo is unclear');
|
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({
|
const exempt = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/admin-api/cleaning/tasks/101/exempt',
|
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)[0], 'exempt');
|
||||||
assert.equal(calls.at(-1)[1].note, 'no cleaning required');
|
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({
|
const members = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/admin-api/cleaning/tasks/101/members',
|
url: '/admin-api/cleaning/tasks/101/members',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const managerAccess = {
|
|||||||
storeIds: ['11']
|
storeIds: ['11']
|
||||||
};
|
};
|
||||||
const issued = [];
|
const issued = [];
|
||||||
|
const audits = [];
|
||||||
const service = new DeviceControlService({
|
const service = new DeviceControlService({
|
||||||
async execute(sql) {
|
async execute(sql) {
|
||||||
if (sql.includes('FROM qipai_devices')) {
|
if (sql.includes('FROM qipai_devices')) {
|
||||||
@@ -26,6 +27,7 @@ const service = new DeviceControlService({
|
|||||||
deviceType: 'CONTROL_BOX', status: 'ONLINE'
|
deviceType: 'CONTROL_BOX', status: 'ONLINE'
|
||||||
}], []];
|
}], []];
|
||||||
}
|
}
|
||||||
|
if (sql.includes('DEVICE_COMMAND_REQUESTED')) audits.push(sql);
|
||||||
return [[], []];
|
return [[], []];
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
@@ -37,6 +39,7 @@ const service = new DeviceControlService({
|
|||||||
});
|
});
|
||||||
const context = {
|
const context = {
|
||||||
tenantId: '7', storeId: '11', roomId: '31', traceId: 'trace',
|
tenantId: '7', storeId: '11', roomId: '31', traceId: 'trace',
|
||||||
|
actorType: 'ADMIN', actorId: '22', ip: '127.0.0.1', userAgent: 'test',
|
||||||
access: managerAccess
|
access: managerAccess
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +78,8 @@ assert.equal(issued.at(-1).payload.action, 'localtask');
|
|||||||
assert.equal(issued.at(-1).payload.switch, 'on');
|
assert.equal(issued.at(-1).payload.switch, 'on');
|
||||||
await service.clearSmartSocketTask(context, 1);
|
await service.clearSmartSocketTask(context, 1);
|
||||||
assert.equal(issued.at(-1).payload.action, 'clearTask');
|
assert.equal(issued.at(-1).payload.action, 'clearTask');
|
||||||
|
assert.equal(audits.length, 14);
|
||||||
|
assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => service.controlSubLock(context, {
|
() => service.controlSubLock(context, {
|
||||||
@@ -160,6 +165,25 @@ const response = await app.inject({
|
|||||||
});
|
});
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.equal(routed.slot1, 'on');
|
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({
|
const socketResponse = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/admin-api/device-control/socket/switch',
|
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({
|
const created = await app.inject({
|
||||||
method: 'POST', url: '/admin-api/devices',
|
method: 'POST', url: '/admin-api/devices',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
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 { ThirdPartyService } from '../dist/third-party/third-party-service.js';
|
||||||
import { DeviceRepository } from '../dist/devices/device-repository.js';
|
import { DeviceRepository } from '../dist/devices/device-repository.js';
|
||||||
import { IotMessageService } from '../dist/devices/iot-message-service.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 { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
||||||
import {
|
import {
|
||||||
executeMigrationPlan,
|
executeMigrationPlan,
|
||||||
@@ -1724,6 +1726,44 @@ async function assertDeviceTopology(pool, context) {
|
|||||||
signalStrength: 22, firmwareVersion: '1.0.1',
|
signalStrength: 22, firmwareVersion: '1.0.1',
|
||||||
snapshot: { slot1: true, door: 'closed' }
|
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, {
|
await repository.addMaintenance(actor, {
|
||||||
assetId: controlBox.assetId, storeId, roomId,
|
assetId: controlBox.assetId, storeId, roomId,
|
||||||
recordType: 'INSPECTION', status: 'OPEN', description: 'M06-B inspection'
|
recordType: 'INSPECTION', status: 'OPEN', description: 'M06-B inspection'
|
||||||
@@ -2051,6 +2091,8 @@ try {
|
|||||||
'Sub-1G parent-child topology',
|
'Sub-1G parent-child topology',
|
||||||
'device status snapshots and maintenance state'
|
'device status snapshots and maintenance state'
|
||||||
,
|
,
|
||||||
|
'manager device command actor audit attribution'
|
||||||
|
,
|
||||||
'13-digit IoT command state transition',
|
'13-digit IoT command state transition',
|
||||||
'QoS 1 duplicate event receive count',
|
'QoS 1 duplicate event receive count',
|
||||||
'ACK correlation without duplicate side effects',
|
'ACK correlation without duplicate side effects',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"pages/recharge/index",
|
"pages/recharge/index",
|
||||||
"pages/cleaner/tasks",
|
"pages/cleaner/tasks",
|
||||||
"pages/manager/dashboard",
|
"pages/manager/dashboard",
|
||||||
|
"pages/manager/operations",
|
||||||
"pages/manager/people",
|
"pages/manager/people",
|
||||||
"pages/manager/order-create",
|
"pages/manager/order-create",
|
||||||
"pages/logs/logs"
|
"pages/logs/logs"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Page({
|
|||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
canReadUsers: false,
|
canReadUsers: false,
|
||||||
|
canReadOperations: false,
|
||||||
stores: [],
|
stores: [],
|
||||||
selectedStoreId: '',
|
selectedStoreId: '',
|
||||||
selectedStoreName: '',
|
selectedStoreName: '',
|
||||||
@@ -98,6 +99,9 @@ Page({
|
|||||||
canReadUsers: access.capabilities.includes('user.read')
|
canReadUsers: access.capabilities.includes('user.read')
|
||||||
|| access.capabilities.includes('tenant.manage')
|
|| access.capabilities.includes('tenant.manage')
|
||||||
|| access.roles.includes('PLATFORM_ADMIN'),
|
|| access.roles.includes('PLATFORM_ADMIN'),
|
||||||
|
canReadOperations: access.capabilities.some((code) => [
|
||||||
|
'cleaning.task.read', 'device.read', 'device.write', 'tenant.manage',
|
||||||
|
].includes(code)) || access.roles.includes('PLATFORM_ADMIN'),
|
||||||
stores,
|
stores,
|
||||||
selectedStoreId,
|
selectedStoreId,
|
||||||
selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '',
|
selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '',
|
||||||
@@ -225,6 +229,13 @@ Page({
|
|||||||
wx.navigateTo({ url: '/pages/manager/people' })
|
wx.navigateTo({ url: '/pages/manager/people' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openOperations() {
|
||||||
|
if (!this.data.canReadOperations || !this.data.selectedStoreId) return
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/manager/operations?storeId=${encodeURIComponent(this.data.selectedStoreId)}&storeName=${encodeURIComponent(this.data.selectedStoreName)}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
manageOrder(event) {
|
manageOrder(event) {
|
||||||
if (!this.data.canWrite) return
|
if (!this.data.canWrite) return
|
||||||
const orderId = event.currentTarget.dataset.orderId
|
const orderId = event.currentTarget.dataset.orderId
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
<button size="mini" bindtap="openPeople">进入</button>
|
<button size="mini" bindtap="openPeople">进入</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{canReadOperations && selectedStoreId}}" class="management-tools">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">保洁与设备</view>
|
||||||
|
<view class="card-meta">任务验收、设备状态与临时开门/电控</view>
|
||||||
|
</view>
|
||||||
|
<button size="mini" bindtap="openOperations">进入</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="summary-grid">
|
<view class="summary-grid">
|
||||||
<view class="summary-card"><strong>{{summary.roomTotal}}</strong><text>房间</text></view>
|
<view class="summary-card"><strong>{{summary.roomTotal}}</strong><text>房间</text></view>
|
||||||
<view class="summary-card success"><strong>{{summary.available}}</strong><text>空闲</text></view>
|
<view class="summary-card success"><strong>{{summary.available}}</strong><text>空闲</text></view>
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
const { request, cents, ensureLogin } = require('../../utils/api.js')
|
||||||
|
|
||||||
|
const cleaningStatusLabels = {
|
||||||
|
WAITING: '待接单',
|
||||||
|
CLAIMED: '已接单',
|
||||||
|
STARTED: '清洁中',
|
||||||
|
SUBMITTED: '待验收',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
EXEMPT: '免清洁',
|
||||||
|
SETTLED: '已结算',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceStatusLabels = {
|
||||||
|
ONLINE: '在线',
|
||||||
|
OFFLINE: '离线',
|
||||||
|
FAULT: '故障',
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceTypeLabels = {
|
||||||
|
CONTROL_BOX: '控制箱',
|
||||||
|
SUB_LOCK: '子锁',
|
||||||
|
SMART_SOCKET: '智能插座',
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
storeId: '',
|
||||||
|
storeName: '',
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
|
canReadCleaning: false,
|
||||||
|
canWriteCleaning: false,
|
||||||
|
canReadDevices: false,
|
||||||
|
canWriteDevices: false,
|
||||||
|
cleaningSummary: {
|
||||||
|
taskTotal: 0,
|
||||||
|
pendingReview: 0,
|
||||||
|
active: 0,
|
||||||
|
rejected: 0,
|
||||||
|
completed: 0,
|
||||||
|
},
|
||||||
|
cleaningTasks: [],
|
||||||
|
devices: [],
|
||||||
|
openAlerts: [],
|
||||||
|
maintenance: [],
|
||||||
|
busyTaskId: '',
|
||||||
|
busyDeviceKey: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLoad(options) {
|
||||||
|
this.setData({
|
||||||
|
storeId: options.storeId || '',
|
||||||
|
storeName: options.storeName || '',
|
||||||
|
})
|
||||||
|
await this.loadOperations()
|
||||||
|
},
|
||||||
|
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
await this.loadOperations()
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadOperations() {
|
||||||
|
if (!this.data.storeId) {
|
||||||
|
this.setData({ errorMessage: '缺少门店信息' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ loading: true, errorMessage: '' })
|
||||||
|
try {
|
||||||
|
await ensureLogin()
|
||||||
|
const me = await request('/auth/me')
|
||||||
|
const access = me.data?.access || { roles: [], capabilities: [] }
|
||||||
|
const isPlatform = access.roles.includes('PLATFORM_ADMIN')
|
||||||
|
const canManageTenant = access.capabilities.includes('tenant.manage') || isPlatform
|
||||||
|
const canReadCleaning = access.capabilities.includes('cleaning.task.read') || canManageTenant
|
||||||
|
const canWriteCleaning = access.capabilities.includes('cleaning.task.write') || canManageTenant
|
||||||
|
const canReadDevices = access.capabilities.some((code) => ['device.read', 'device.write'].includes(code)) || canManageTenant
|
||||||
|
const canWriteDevices = access.capabilities.includes('device.write') || canManageTenant
|
||||||
|
this.setData({ canReadCleaning, canWriteCleaning, canReadDevices, canWriteDevices })
|
||||||
|
await Promise.all([
|
||||||
|
canReadCleaning ? this.loadCleaning() : Promise.resolve(),
|
||||||
|
canReadDevices ? this.loadDevices() : Promise.resolve(),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ errorMessage: error.message || '运营数据加载失败' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadCleaning() {
|
||||||
|
const storeId = encodeURIComponent(this.data.storeId)
|
||||||
|
const [tasksResponse, statsResponse] = await Promise.all([
|
||||||
|
request(`/management/cleaning/tasks?page=1&pageSize=50&storeId=${storeId}`),
|
||||||
|
request(`/management/cleaning/statistics?storeId=${storeId}`),
|
||||||
|
])
|
||||||
|
const cleaningTasks = (tasksResponse.data?.items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
statusText: cleaningStatusLabels[item.status] || item.status,
|
||||||
|
rewardText: cents(item.rewardCents),
|
||||||
|
roomText: item.roomName ? `${item.roomName} · ${item.roomNo || ''}` : `房间 ${item.roomId}`,
|
||||||
|
canReview: item.status === 'SUBMITTED',
|
||||||
|
canExempt: ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(item.status),
|
||||||
|
}))
|
||||||
|
this.setData({
|
||||||
|
cleaningTasks,
|
||||||
|
cleaningSummary: statsResponse.data?.summary || this.data.cleaningSummary,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadDevices() {
|
||||||
|
const storeId = encodeURIComponent(this.data.storeId)
|
||||||
|
const response = await request(`/management/device-topology?storeId=${storeId}`)
|
||||||
|
const topology = response.data || {}
|
||||||
|
const devices = (topology.assets || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
typeText: deviceTypeLabels[item.deviceType] || item.deviceType,
|
||||||
|
statusText: deviceStatusLabels[item.status] || item.status,
|
||||||
|
roomText: item.roomId ? `房间 ${item.roomId}` : '门店公共设备',
|
||||||
|
lastSeenText: this.formatTime(item.lastSeenAt || item.lastHeartbeatAt),
|
||||||
|
}))
|
||||||
|
this.setData({
|
||||||
|
devices,
|
||||||
|
openAlerts: topology.openAlerts || [],
|
||||||
|
maintenance: (topology.maintenance || []).filter((item) => item.status === 'OPEN'),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
reviewTask(event) {
|
||||||
|
if (!this.data.canWriteCleaning) return
|
||||||
|
const taskId = event.currentTarget.dataset.taskId
|
||||||
|
const action = event.currentTarget.dataset.action
|
||||||
|
if (action === 'reject') {
|
||||||
|
wx.showModal({
|
||||||
|
title: '驳回保洁任务',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '请输入补做原因',
|
||||||
|
success: ({ confirm, content }) => {
|
||||||
|
const reason = String(content || '').trim()
|
||||||
|
if (confirm && reason) this.submitCleaningAction(taskId, 'reject', { reason }, '任务已驳回')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const labels = action === 'complete'
|
||||||
|
? { title: '验收通过', content: '确认照片与现场清洁结果已达标吗?', toast: '验收已完成' }
|
||||||
|
: { title: '设为免清洁', content: '确认该房间本次无需清洁吗?', toast: '已设为免清洁' }
|
||||||
|
wx.showModal({
|
||||||
|
title: labels.title,
|
||||||
|
content: labels.content,
|
||||||
|
success: ({ confirm }) => {
|
||||||
|
if (confirm) this.submitCleaningAction(taskId, action, { note: `管理员小程序:${labels.title}` }, labels.toast)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitCleaningAction(taskId, action, data, toast) {
|
||||||
|
if (!taskId || this.data.busyTaskId) return
|
||||||
|
this.setData({ busyTaskId: taskId, errorMessage: '' })
|
||||||
|
try {
|
||||||
|
await request(`/management/cleaning/tasks/${encodeURIComponent(taskId)}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
await this.loadCleaning()
|
||||||
|
wx.showToast({ title: toast, icon: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ errorMessage: error.message || '保洁任务处理失败' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ busyTaskId: '' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
controlDevice(event) {
|
||||||
|
if (!this.data.canWriteDevices) return
|
||||||
|
const { roomId, action } = event.currentTarget.dataset
|
||||||
|
if (!roomId || !action) return
|
||||||
|
const actions = {
|
||||||
|
'door-open': {
|
||||||
|
title: '临时开门',
|
||||||
|
endpoint: '/management/device-control/door',
|
||||||
|
data: { order: 'open', holdopen: 0, delayTime: 4 },
|
||||||
|
},
|
||||||
|
'power-on': {
|
||||||
|
title: '全屋通电',
|
||||||
|
endpoint: '/management/device-control/power',
|
||||||
|
data: { slotall: 'on' },
|
||||||
|
},
|
||||||
|
'power-off': {
|
||||||
|
title: '全屋断电',
|
||||||
|
endpoint: '/management/device-control/power',
|
||||||
|
data: { slotall: 'off' },
|
||||||
|
},
|
||||||
|
'socket-on': {
|
||||||
|
title: '插座通电',
|
||||||
|
endpoint: '/management/device-control/socket/switch',
|
||||||
|
data: { on: true, slotNum: 1 },
|
||||||
|
},
|
||||||
|
'socket-off': {
|
||||||
|
title: '插座断电',
|
||||||
|
endpoint: '/management/device-control/socket/switch',
|
||||||
|
data: { on: false, slotNum: 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const selected = actions[action]
|
||||||
|
if (!selected) return
|
||||||
|
wx.showModal({
|
||||||
|
title: selected.title,
|
||||||
|
content: `确认对房间 ${roomId} 执行“${selected.title}”吗?`,
|
||||||
|
success: ({ confirm }) => {
|
||||||
|
if (confirm) this.submitDeviceControl(roomId, action, selected)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitDeviceControl(roomId, action, selected) {
|
||||||
|
const busyDeviceKey = `${roomId}:${action}`
|
||||||
|
if (this.data.busyDeviceKey) return
|
||||||
|
this.setData({ busyDeviceKey, errorMessage: '' })
|
||||||
|
try {
|
||||||
|
await request(selected.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { storeId: this.data.storeId, roomId, ...selected.data },
|
||||||
|
})
|
||||||
|
wx.showToast({ title: '指令已发送', icon: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ errorMessage: error.message || '设备控制失败' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ busyDeviceKey: '' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(value) {
|
||||||
|
if (!value) return '暂无心跳'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value)
|
||||||
|
const pad = (part) => String(part).padStart(2, '0')
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "保洁与设备",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<scroll-view class="scrollarea" scroll-y type="list">
|
||||||
|
<view class="container operations-page">
|
||||||
|
<view class="page-heading">
|
||||||
|
<view>
|
||||||
|
<view class="title">保洁与设备</view>
|
||||||
|
<view class="subtitle">{{storeName || '当前门店'}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="permission-tag">{{canWriteCleaning || canWriteDevices ? '可操作' : '只读'}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||||
|
<view wx:if="{{loading}}" class="loading">正在加载运营数据...</view>
|
||||||
|
|
||||||
|
<block wx:if="{{canReadCleaning}}">
|
||||||
|
<view class="section-title">保洁任务</view>
|
||||||
|
<view class="summary-grid">
|
||||||
|
<view class="summary-card"><strong>{{cleaningSummary.taskTotal}}</strong><text>任务</text></view>
|
||||||
|
<view class="summary-card warning"><strong>{{cleaningSummary.pendingReview}}</strong><text>待验收</text></view>
|
||||||
|
<view class="summary-card primary"><strong>{{cleaningSummary.active}}</strong><text>处理中</text></view>
|
||||||
|
<view class="summary-card danger"><strong>{{cleaningSummary.rejected}}</strong><text>已驳回</text></view>
|
||||||
|
<view class="summary-card success"><strong>{{cleaningSummary.completed}}</strong><text>已完成</text></view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!loading && cleaningTasks.length === 0}}" class="empty">当前门店暂无保洁任务</view>
|
||||||
|
<view wx:for="{{cleaningTasks}}" wx:key="id" class="operation-card">
|
||||||
|
<view class="card-main">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">{{item.roomText}}</view>
|
||||||
|
<view class="card-meta">{{item.taskNo}} · {{item.rewardText}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="status-pill status-{{item.status}}">{{item.statusText}}</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{item.requirement}}" class="detail-line">要求:{{item.requirement}}</view>
|
||||||
|
<view wx:if="{{item.rejectReason}}" class="reject-reason">驳回:{{item.rejectReason}}</view>
|
||||||
|
<view wx:if="{{canWriteCleaning && (item.canReview || item.canExempt)}}" class="card-actions">
|
||||||
|
<button wx:if="{{item.canReview}}" size="mini" type="primary" loading="{{busyTaskId === item.id}}" data-task-id="{{item.id}}" data-action="complete" bindtap="reviewTask">验收通过</button>
|
||||||
|
<button wx:if="{{item.canReview}}" size="mini" data-task-id="{{item.id}}" data-action="reject" bindtap="reviewTask">驳回补做</button>
|
||||||
|
<button wx:if="{{item.canExempt}}" size="mini" data-task-id="{{item.id}}" data-action="exempt" bindtap="reviewTask">免清洁</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block wx:if="{{canReadDevices}}">
|
||||||
|
<view class="section-heading">
|
||||||
|
<view class="section-title">设备状态</view>
|
||||||
|
<view class="section-count">告警 {{openAlerts.length}} · 维护 {{maintenance.length}}</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!loading && devices.length === 0}}" class="empty">当前门店暂无设备</view>
|
||||||
|
<view wx:for="{{devices}}" wx:key="id" class="operation-card">
|
||||||
|
<view class="card-main">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">{{item.typeText}} · {{item.deviceId}}</view>
|
||||||
|
<view class="card-meta">{{item.roomText}} / {{item.model}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="status-pill device-{{item.status}}">{{item.statusText}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="detail-line">最近心跳:{{item.lastSeenText}}</view>
|
||||||
|
<view wx:if="{{canWriteDevices && item.roomId && item.deviceType === 'CONTROL_BOX'}}" class="card-actions controls">
|
||||||
|
<button size="mini" loading="{{busyDeviceKey === item.roomId + ':door-open'}}" data-room-id="{{item.roomId}}" data-action="door-open" bindtap="controlDevice">临时开门</button>
|
||||||
|
<button size="mini" data-room-id="{{item.roomId}}" data-action="power-on" bindtap="controlDevice">全屋通电</button>
|
||||||
|
<button size="mini" type="warn" data-room-id="{{item.roomId}}" data-action="power-off" bindtap="controlDevice">全屋断电</button>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{canWriteDevices && item.roomId && item.deviceType === 'SMART_SOCKET'}}" class="card-actions controls">
|
||||||
|
<button size="mini" data-room-id="{{item.roomId}}" data-action="socket-on" bindtap="controlDevice">插座通电</button>
|
||||||
|
<button size="mini" type="warn" data-room-id="{{item.roomId}}" data-action="socket-off" bindtap="controlDevice">插座断电</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<view wx:if="{{!loading && !canReadCleaning && !canReadDevices}}" class="empty">当前账号没有保洁或设备查看权限</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
.operations-page {
|
||||||
|
padding-bottom: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading,
|
||||||
|
.card-main,
|
||||||
|
.section-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle,
|
||||||
|
.card-meta,
|
||||||
|
.detail-line,
|
||||||
|
.section-count {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 23rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-tag,
|
||||||
|
.status-pill {
|
||||||
|
background: #eef2ff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #3730a3;
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 30rpx 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-count {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1rpx solid #e2e8f0;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14rpx 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card strong {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card text {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.warning strong { color: #c2410c; }
|
||||||
|
.summary-card.primary strong { color: #2563eb; }
|
||||||
|
.summary-card.danger strong { color: #b91c1c; }
|
||||||
|
.summary-card.success strong { color: #15803d; }
|
||||||
|
|
||||||
|
.operation-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx solid #e2e8f0;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
padding: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-line,
|
||||||
|
.reject-reason {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-reason {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 23rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-SUBMITTED { background: #fef3c7; color: #92400e; }
|
||||||
|
.status-COMPLETED,
|
||||||
|
.status-SETTLED,
|
||||||
|
.device-ONLINE { background: #dcfce7; color: #166534; }
|
||||||
|
.status-REJECTED,
|
||||||
|
.device-FAULT { background: #fee2e2; color: #991b1b; }
|
||||||
|
.status-EXEMPT { background: #f1f5f9; color: #475569; }
|
||||||
|
.device-OFFLINE { background: #e2e8f0; color: #334155; }
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const read = (path) => readFileSync(join(root, path), 'utf8');
|
|||||||
|
|
||||||
const appJson = JSON.parse(read('miniapp/app.json'));
|
const appJson = JSON.parse(read('miniapp/app.json'));
|
||||||
assert.ok(appJson.pages.includes('pages/manager/dashboard'));
|
assert.ok(appJson.pages.includes('pages/manager/dashboard'));
|
||||||
|
assert.ok(appJson.pages.includes('pages/manager/operations'));
|
||||||
assert.ok(appJson.pages.includes('pages/manager/people'));
|
assert.ok(appJson.pages.includes('pages/manager/people'));
|
||||||
assert.ok(appJson.pages.includes('pages/manager/order-create'));
|
assert.ok(appJson.pages.includes('pages/manager/order-create'));
|
||||||
|
|
||||||
@@ -38,7 +39,9 @@ for (const pattern of [
|
|||||||
'store.operation.write',
|
'store.operation.write',
|
||||||
'tenant.manage',
|
'tenant.manage',
|
||||||
'canReadUsers',
|
'canReadUsers',
|
||||||
|
'canReadOperations',
|
||||||
'openPeople',
|
'openPeople',
|
||||||
|
'openOperations',
|
||||||
'changeRoomStatus',
|
'changeRoomStatus',
|
||||||
'toggleRoomConfiguration',
|
'toggleRoomConfiguration',
|
||||||
'activeOrders',
|
'activeOrders',
|
||||||
@@ -119,6 +122,41 @@ const mysqlPaginationRepositories = peopleRoutes
|
|||||||
assert.doesNotMatch(mysqlPaginationRepositories, /LIMIT \? OFFSET \?/);
|
assert.doesNotMatch(mysqlPaginationRepositories, /LIMIT \? OFFSET \?/);
|
||||||
assert.match(mysqlPaginationRepositories, /Math\.trunc/);
|
assert.match(mysqlPaginationRepositories, /Math\.trunc/);
|
||||||
|
|
||||||
|
const operations = read('miniapp/pages/manager/operations.js')
|
||||||
|
+ read('miniapp/pages/manager/operations.wxml')
|
||||||
|
+ read('miniapp/pages/manager/operations.wxss');
|
||||||
|
for (const pattern of [
|
||||||
|
'/management/cleaning/tasks?page=1&pageSize=50&storeId=${storeId}',
|
||||||
|
'/management/cleaning/statistics?storeId=${storeId}',
|
||||||
|
'/management/device-topology?storeId=${storeId}',
|
||||||
|
'/management/device-control/door',
|
||||||
|
'/management/device-control/power',
|
||||||
|
'/management/device-control/socket/switch',
|
||||||
|
'cleaning.task.write',
|
||||||
|
'device.write',
|
||||||
|
'验收通过',
|
||||||
|
'驳回补做',
|
||||||
|
'免清洁',
|
||||||
|
'临时开门',
|
||||||
|
'全屋断电'
|
||||||
|
]) {
|
||||||
|
assert.match(operations, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationRoutes = read('backend/src/routes/cleaning.ts')
|
||||||
|
+ read('backend/src/routes/devices.ts')
|
||||||
|
+ read('backend/src/routes/device-control.ts');
|
||||||
|
for (const pattern of [
|
||||||
|
'/app-api/management/cleaning/tasks',
|
||||||
|
'/app-api/management/cleaning/statistics',
|
||||||
|
'/app-api/management/devices',
|
||||||
|
'/app-api/management/device-topology',
|
||||||
|
'/app-api/management/device-control${suffix}'
|
||||||
|
]) {
|
||||||
|
assert.match(operationRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
}
|
||||||
|
assert.match(read('backend/src/devices/device-control-service.ts'), /DEVICE_COMMAND_REQUESTED/);
|
||||||
|
|
||||||
const routes = read('backend/src/routes/store-room-management.ts');
|
const routes = read('backend/src/routes/store-room-management.ts');
|
||||||
for (const pattern of [
|
for (const pattern of [
|
||||||
'/app-api/management/stores',
|
'/app-api/management/stores',
|
||||||
|
|||||||
Reference in New Issue
Block a user