feat(M08-B): 接入保洁任务端基础流程
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs"
|
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-route.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
} from './routes/device-control.js';
|
} from './routes/device-control.js';
|
||||||
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
|
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
|
||||||
import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js';
|
import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js';
|
||||||
|
import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -72,6 +73,7 @@ export interface BuildAppOptions {
|
|||||||
deviceControl?: DeviceControlRouteOptions;
|
deviceControl?: DeviceControlRouteOptions;
|
||||||
members?: MemberRouteOptions;
|
members?: MemberRouteOptions;
|
||||||
recharge?: RechargeRouteOptions;
|
recharge?: RechargeRouteOptions;
|
||||||
|
cleaning?: CleaningRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -170,6 +172,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
if (options.recharge) {
|
if (options.recharge) {
|
||||||
await registerRechargeRoutes(app, options.recharge);
|
await registerRechargeRoutes(app, options.recharge);
|
||||||
}
|
}
|
||||||
|
if (options.cleaning) {
|
||||||
|
await registerCleaningRoutes(app, options.cleaning);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,13 +36,20 @@ export class RbacRepository {
|
|||||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||||
INNER JOIN qipai_permissions p ON
|
INNER JOIN qipai_permissions p ON
|
||||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||||
|
OR (r.code = 'CLEANER'
|
||||||
|
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||||
|
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||||
OR (r.code = 'STORE_ADMIN'
|
OR (r.code = 'STORE_ADMIN'
|
||||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||||
'store.operation.read', 'store.operation.write',
|
'store.operation.read', 'store.operation.write',
|
||||||
'device.read', 'device.write'))
|
'device.read', 'device.write',
|
||||||
|
'cleaning.task.read', 'cleaning.task.write',
|
||||||
|
'cleaning.statistics.read'))
|
||||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||||
'device.read', 'device.write'))
|
'device.read', 'device.write',
|
||||||
|
'cleaning.task.read', 'cleaning.task.write',
|
||||||
|
'cleaning.statistics.read'))
|
||||||
WHERE r.tenant_id = ?`,
|
WHERE r.tenant_id = ?`,
|
||||||
[tenantId, tenantId]
|
[tenantId, tenantId]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
|
||||||
|
export const cleaningTaskStatuses = [
|
||||||
|
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
|
||||||
|
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
|
||||||
|
] as const;
|
||||||
|
export type CleaningTaskStatus = typeof cleaningTaskStatuses[number];
|
||||||
|
|
||||||
|
export class CleaningTaskError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningActor {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
access: AccessProfile;
|
||||||
|
traceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CleaningTaskRow extends RowDataPacket {
|
||||||
|
id: string;
|
||||||
|
taskNo: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
roomNo: string;
|
||||||
|
orderId: string | null;
|
||||||
|
orderNo: string | null;
|
||||||
|
status: CleaningTaskStatus;
|
||||||
|
cleanerUserId: string | null;
|
||||||
|
priority: number;
|
||||||
|
rewardCents: number;
|
||||||
|
requirement: string;
|
||||||
|
photoUrlsJson: string | string[] | null;
|
||||||
|
rejectReason: string;
|
||||||
|
claimedAt: Date | null;
|
||||||
|
startedAt: Date | null;
|
||||||
|
submittedAt: Date | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
interface CountRow extends RowDataPacket { total: number }
|
||||||
|
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
|
||||||
|
interface AmountRow extends RowDataPacket { amount: number | null }
|
||||||
|
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null }
|
||||||
|
|
||||||
|
export class CleaningTaskRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
|
||||||
|
this.assertCleaner(input.access, 'read');
|
||||||
|
const status = input.status ?? 'WAITING';
|
||||||
|
const where = [
|
||||||
|
't.tenant_id = ?',
|
||||||
|
't.deleted_at IS NULL',
|
||||||
|
't.status = ?',
|
||||||
|
storeScopeSql(input.access, 't.store_id')
|
||||||
|
];
|
||||||
|
const params: Array<string | number> = [input.tenantId, status];
|
||||||
|
return this.listByWhere(where, params, input.page, input.pageSize, 't.priority ASC, t.created_at ASC');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMine(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
|
||||||
|
this.assertCleaner(input.access, 'read');
|
||||||
|
const where = [
|
||||||
|
't.tenant_id = ?',
|
||||||
|
't.deleted_at IS NULL',
|
||||||
|
't.cleaner_user_id = ?'
|
||||||
|
];
|
||||||
|
const params: Array<string | number> = [input.tenantId, input.userId];
|
||||||
|
if (input.status) {
|
||||||
|
where.push('t.status = ?');
|
||||||
|
params.push(input.status);
|
||||||
|
}
|
||||||
|
return this.listByWhere(where, params, input.page, input.pageSize, 't.updated_at DESC, t.id DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
async claim(input: CleaningActor & { taskId: string }) {
|
||||||
|
this.assertCleaner(input.access, 'write');
|
||||||
|
await this.assertStoreVisible(input, input.taskId);
|
||||||
|
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||||
|
`UPDATE qipai_cleaning_tasks
|
||||||
|
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3)
|
||||||
|
WHERE tenant_id = ? AND id = ? AND status = 'WAITING' AND cleaner_user_id IS NULL
|
||||||
|
AND deleted_at IS NULL`,
|
||||||
|
[input.userId, input.tenantId, input.taskId]
|
||||||
|
);
|
||||||
|
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE');
|
||||||
|
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', '');
|
||||||
|
return this.getMineTask(input, input.taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(input: CleaningActor & { taskId: string }) {
|
||||||
|
return this.moveMine(input, 'CLAIMED', 'STARTED', 'START', 'started_at', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
|
||||||
|
const task = await this.moveMine(
|
||||||
|
input,
|
||||||
|
'STARTED',
|
||||||
|
'SUBMITTED',
|
||||||
|
'SUBMIT',
|
||||||
|
'submitted_at',
|
||||||
|
input.note ?? '',
|
||||||
|
{ photo_urls_json: JSON.stringify(input.photoUrls.slice(0, 9)) }
|
||||||
|
);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stats(input: CleaningActor) {
|
||||||
|
this.assertCleaner(input.access, 'read');
|
||||||
|
const [counts] = await this.pool.execute<StatusCountRow[]>(
|
||||||
|
`SELECT status, COUNT(*) AS total
|
||||||
|
FROM qipai_cleaning_tasks
|
||||||
|
WHERE tenant_id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
|
||||||
|
GROUP BY status`,
|
||||||
|
[input.tenantId, input.userId]
|
||||||
|
);
|
||||||
|
const [amountRows] = await this.pool.execute<AmountRow[]>(
|
||||||
|
`SELECT COALESCE(SUM(reward_cents), 0) AS amount
|
||||||
|
FROM qipai_cleaning_tasks
|
||||||
|
WHERE tenant_id = ? AND cleaner_user_id = ? AND status IN ('SUBMITTED', 'COMPLETED')
|
||||||
|
AND settled_at IS NULL AND deleted_at IS NULL`,
|
||||||
|
[input.tenantId, input.userId]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
byStatus: Object.fromEntries(counts.map((row) => [row.status, Number(row.total)])),
|
||||||
|
pendingSettlementCents: Number(amountRows[0]?.amount ?? 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listByWhere(
|
||||||
|
where: string[],
|
||||||
|
params: Array<string | number>,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
orderBy: string
|
||||||
|
) {
|
||||||
|
const whereSql = where.join(' AND ');
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const [counts] = await this.pool.execute<CountRow[]>(
|
||||||
|
`SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const [rows] = await this.pool.execute<CleaningTaskRow[]>(
|
||||||
|
`SELECT t.id, t.task_no AS taskNo, t.store_id AS storeId, s.name AS storeName,
|
||||||
|
t.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
|
||||||
|
t.order_id AS orderId, o.order_no AS orderNo, t.status,
|
||||||
|
t.cleaner_user_id AS cleanerUserId, t.priority, t.reward_cents AS rewardCents,
|
||||||
|
t.requirement, t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
|
||||||
|
t.claimed_at AS claimedAt, t.started_at AS startedAt,
|
||||||
|
t.submitted_at AS submittedAt, t.completed_at AS completedAt,
|
||||||
|
t.created_at AS createdAt, t.updated_at AS updatedAt
|
||||||
|
FROM qipai_cleaning_tasks t
|
||||||
|
INNER JOIN qipai_stores s ON s.tenant_id = t.tenant_id AND s.id = t.store_id
|
||||||
|
INNER JOIN qipai_rooms r ON r.tenant_id = t.tenant_id AND r.id = t.room_id
|
||||||
|
LEFT JOIN qipai_orders o ON o.tenant_id = t.tenant_id AND o.id = t.order_id
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, pageSize, offset]
|
||||||
|
);
|
||||||
|
return { items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0), page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMineTask(input: CleaningActor, taskId: string) {
|
||||||
|
const result = await this.listMine({ ...input, page: 1, pageSize: 50 });
|
||||||
|
const task = result.items.find((item) => item.id === taskId);
|
||||||
|
if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async moveMine(
|
||||||
|
input: CleaningActor & { taskId: string },
|
||||||
|
from: CleaningTaskStatus,
|
||||||
|
to: CleaningTaskStatus,
|
||||||
|
action: string,
|
||||||
|
timestampColumn: 'started_at' | 'submitted_at',
|
||||||
|
note: string,
|
||||||
|
extra?: { photo_urls_json: string }
|
||||||
|
) {
|
||||||
|
this.assertCleaner(input.access, 'write');
|
||||||
|
const setExtra = extra ? ', photo_urls_json = ?' : '';
|
||||||
|
const params: Array<string | number> = extra
|
||||||
|
? [extra.photo_urls_json, input.tenantId, input.taskId, input.userId, from]
|
||||||
|
: [input.tenantId, input.taskId, input.userId, from];
|
||||||
|
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||||
|
`UPDATE qipai_cleaning_tasks
|
||||||
|
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)${setExtra}
|
||||||
|
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ?
|
||||||
|
AND deleted_at IS NULL`,
|
||||||
|
[to, ...params]
|
||||||
|
);
|
||||||
|
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||||
|
await this.recordEvent(input, input.taskId, from, to, action, note);
|
||||||
|
return this.getMineTask(input, input.taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStoreVisible(input: CleaningActor, taskId: string) {
|
||||||
|
if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||||
|
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT store_id AS storeId FROM qipai_cleaning_tasks
|
||||||
|
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||||
|
[input.tenantId, taskId]
|
||||||
|
);
|
||||||
|
const storeId = rows[0]?.storeId;
|
||||||
|
if (!storeId || !input.access.storeIds.includes(String(storeId))) {
|
||||||
|
throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordEvent(
|
||||||
|
input: CleaningActor,
|
||||||
|
taskId: string,
|
||||||
|
from: CleaningTaskStatus,
|
||||||
|
to: CleaningTaskStatus,
|
||||||
|
action: string,
|
||||||
|
note: string
|
||||||
|
) {
|
||||||
|
await this.pool.execute(
|
||||||
|
`INSERT IGNORE INTO qipai_cleaning_task_events
|
||||||
|
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
|
||||||
|
[input.tenantId, taskId, from, to, action, input.userId, input.traceId, note.slice(0, 512)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCleaner(access: AccessProfile, mode: 'read' | 'write') {
|
||||||
|
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
|
||||||
|
if (!access.capabilities.includes(permission)
|
||||||
|
&& !access.capabilities.includes('tenant.manage')
|
||||||
|
&& !access.roles.includes('PLATFORM_ADMIN')) {
|
||||||
|
throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeScopeSql(access: AccessProfile, storeExpression: string) {
|
||||||
|
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
|
||||||
|
return '1 = 1';
|
||||||
|
}
|
||||||
|
if (access.storeIds.length === 0) return '1 = 0';
|
||||||
|
return `${storeExpression} IN (${access.storeIds.map((id) => Number(id)).join(',')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicTask(row: CleaningTaskRow) {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
taskNo: row.taskNo,
|
||||||
|
storeId: String(row.storeId),
|
||||||
|
storeName: row.storeName,
|
||||||
|
roomId: String(row.roomId),
|
||||||
|
roomName: row.roomName,
|
||||||
|
roomNo: row.roomNo,
|
||||||
|
orderId: row.orderId === null ? null : String(row.orderId),
|
||||||
|
orderNo: row.orderNo,
|
||||||
|
status: row.status,
|
||||||
|
cleanerUserId: row.cleanerUserId === null ? null : String(row.cleanerUserId),
|
||||||
|
priority: Number(row.priority),
|
||||||
|
rewardCents: Number(row.rewardCents),
|
||||||
|
requirement: row.requirement,
|
||||||
|
photoUrls: parseJsonArray(row.photoUrlsJson),
|
||||||
|
rejectReason: row.rejectReason,
|
||||||
|
claimedAt: row.claimedAt,
|
||||||
|
startedAt: row.startedAt,
|
||||||
|
submittedAt: row.submittedAt,
|
||||||
|
completedAt: row.completedAt,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray(value: string | string[] | null): string[] {
|
||||||
|
if (Array.isArray(value)) return value.map(String);
|
||||||
|
if (!value) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
|
'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
|
||||||
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
|
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
|
||||||
'database/migrations/2026062423_m07c_benefits.up.sql',
|
'database/migrations/2026062423_m07c_benefits.up.sql',
|
||||||
'database/migrations/2026062524_m08a_recharge_wechat.up.sql'
|
'database/migrations/2026062524_m08a_recharge_wechat.up.sql',
|
||||||
|
'database/migrations/2026062525_m08b_cleaner_tasks.up.sql'
|
||||||
],
|
],
|
||||||
verify: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
@@ -70,9 +71,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
|
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
|
||||||
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
|
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
|
||||||
'database/migrations/2026062423_m07c_benefits.verify.sql',
|
'database/migrations/2026062423_m07c_benefits.verify.sql',
|
||||||
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql'
|
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql',
|
||||||
|
'database/migrations/2026062525_m08b_cleaner_tasks.verify.sql'
|
||||||
],
|
],
|
||||||
down: [
|
down: [
|
||||||
|
'database/migrations/2026062525_m08b_cleaner_tasks.down.sql',
|
||||||
'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
|
'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
|
||||||
'database/migrations/2026062423_m07c_benefits.down.sql',
|
'database/migrations/2026062423_m07c_benefits.down.sql',
|
||||||
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
|
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
|
||||||
@@ -235,7 +238,8 @@ export async function executeMigrationPlan(
|
|||||||
3, 3, 9, 1,
|
3, 3, 9, 1,
|
||||||
1, 1, 1, 4, 7, 1,
|
1, 1, 1, 4, 7, 1,
|
||||||
1, 1, 7, 7, 1,
|
1, 1, 7, 7, 1,
|
||||||
5, 10, 7, 3, 1
|
5, 10, 7, 3, 1,
|
||||||
|
2, 8, 4, 3, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import {
|
||||||
|
CleaningTaskError,
|
||||||
|
cleaningTaskStatuses,
|
||||||
|
type CleaningActor,
|
||||||
|
type CleaningTaskRepository
|
||||||
|
} from '../cleaning/cleaning-task-repository.js';
|
||||||
|
|
||||||
|
const listSchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(50).default(20),
|
||||||
|
status: z.enum(cleaningTaskStatuses).optional()
|
||||||
|
});
|
||||||
|
const paramsSchema = z.object({ taskId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||||
|
const submitSchema = z.object({
|
||||||
|
photoUrls: z.array(z.string().url()).max(9).default([]),
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface CleaningRouteOptions {
|
||||||
|
repository: Pick<CleaningTaskRepository, 'listHall' | 'listMine' | 'claim' | 'start' | 'submit' | 'stats'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerCleaningRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: CleaningRouteOptions
|
||||||
|
): Promise<void> {
|
||||||
|
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = listSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listHall({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/app-api/cleaning/tasks/mine', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = listSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listMine({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/app-api/cleaning/stats', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.stats(actor),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.claim({ ...actor, taskId: params.data.taskId }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.start({ ...actor, taskId: params.data.taskId }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/tasks/:taskId/submit', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const body = submitSchema.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.submit({
|
||||||
|
...actor,
|
||||||
|
taskId: params.data.taskId,
|
||||||
|
photoUrls: body.data.photoUrls,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireActor(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: CleaningRouteOptions,
|
||||||
|
mode: 'read' | 'write'
|
||||||
|
): Promise<CleaningActor | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization,
|
||||||
|
options.authRepository,
|
||||||
|
options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||||
|
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
|
||||||
|
if (!access.capabilities.includes(permission)
|
||||||
|
&& !access.capabilities.includes('tenant.manage')
|
||||||
|
&& !access.roles.includes('PLATFORM_ADMIN')) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'CLEANING_TASK_FORBIDDEN',
|
||||||
|
message: 'Cleaning task permission is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
userId: auth.session.user.id,
|
||||||
|
access,
|
||||||
|
traceId: request.traceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CleaningTaskError)) throw error;
|
||||||
|
const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN' ? 403 : 409;
|
||||||
|
return reply.status(statusCode).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'The cleaning task request cannot be completed.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'INVALID_CLEANING_TASK_REQUEST',
|
||||||
|
message: 'The cleaning task request is invalid.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import { MemberProfileService } from './wallets/member-profile-service.js';
|
|||||||
import { RechargeService } from './wallets/recharge-service.js';
|
import { RechargeService } from './wallets/recharge-service.js';
|
||||||
import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
|
import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
|
||||||
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
|
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
|
||||||
|
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
@@ -184,6 +185,12 @@ const app = await buildApp({
|
|||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
|
},
|
||||||
|
cleaning: {
|
||||||
|
repository: new CleaningTaskRepository(pool),
|
||||||
|
authRepository,
|
||||||
|
accessControl,
|
||||||
|
jwtSecret: config.auth.jwtSecret
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildApp } from '../dist/app.js';
|
||||||
|
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||||
|
|
||||||
|
const secret = 'test-only-cleaning-route-secret';
|
||||||
|
const token = signAccessToken({
|
||||||
|
sub: '31', sid: '9c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||||
|
tid: '7', aid: '9', rv: 1
|
||||||
|
}, secret, 900);
|
||||||
|
const forbiddenToken = signAccessToken({
|
||||||
|
sub: '32', sid: '8c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||||
|
tid: '7', aid: '9', rv: 1
|
||||||
|
}, secret, 900);
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
const app = await buildApp({
|
||||||
|
cleaning: {
|
||||||
|
jwtSecret: secret,
|
||||||
|
authRepository: {
|
||||||
|
async validateSession(sessionId) {
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
tenantId: '7',
|
||||||
|
platformAppId: '9',
|
||||||
|
expiresAt: new Date(Date.now() + 60000),
|
||||||
|
user: {
|
||||||
|
id: sessionId.startsWith('8') ? '32' : '31',
|
||||||
|
tenantId: '7',
|
||||||
|
userType: 'CUSTOMER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
roleVersion: 1,
|
||||||
|
nickname: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
phone: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accessControl: {
|
||||||
|
async getAccessProfile(tenantId, userId) {
|
||||||
|
return userId === '31'
|
||||||
|
? {
|
||||||
|
roles: ['CLEANER'],
|
||||||
|
capabilities: ['cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'],
|
||||||
|
storeIds: ['11']
|
||||||
|
}
|
||||||
|
: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
repository: {
|
||||||
|
async listHall(input) {
|
||||||
|
calls.push(['listHall', input]);
|
||||||
|
return { items: [task('WAITING')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||||
|
},
|
||||||
|
async listMine(input) {
|
||||||
|
calls.push(['listMine', input]);
|
||||||
|
return { items: [task('CLAIMED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||||
|
},
|
||||||
|
async claim(input) {
|
||||||
|
calls.push(['claim', input]);
|
||||||
|
return task('CLAIMED');
|
||||||
|
},
|
||||||
|
async start(input) {
|
||||||
|
calls.push(['start', input]);
|
||||||
|
return task('STARTED');
|
||||||
|
},
|
||||||
|
async submit(input) {
|
||||||
|
calls.push(['submit', input]);
|
||||||
|
return { ...task('SUBMITTED'), photoUrls: input.photoUrls };
|
||||||
|
},
|
||||||
|
async stats(input) {
|
||||||
|
calls.push(['stats', input]);
|
||||||
|
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hall = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(hall.statusCode, 200);
|
||||||
|
assert.equal(hall.json().data.items[0].status, 'WAITING');
|
||||||
|
assert.equal(calls.at(-1)[1].tenantId, '7');
|
||||||
|
assert.equal(calls.at(-1)[1].userId, '31');
|
||||||
|
|
||||||
|
const mine = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/app-api/cleaning/tasks/mine?status=CLAIMED',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(mine.statusCode, 200);
|
||||||
|
assert.equal(calls.at(-1)[0], 'listMine');
|
||||||
|
assert.equal(calls.at(-1)[1].status, 'CLAIMED');
|
||||||
|
|
||||||
|
const claim = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/app-api/cleaning/tasks/101/claim',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(claim.statusCode, 200);
|
||||||
|
assert.equal(calls.at(-1)[0], 'claim');
|
||||||
|
assert.equal(calls.at(-1)[1].taskId, '101');
|
||||||
|
|
||||||
|
const start = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/app-api/cleaning/tasks/101/start',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(start.statusCode, 200);
|
||||||
|
assert.equal(calls.at(-1)[0], 'start');
|
||||||
|
|
||||||
|
const submit = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/app-api/cleaning/tasks/101/submit',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { photoUrls: ['https://api.txyundm.cn/uploads/cleaning/101.jpg'], note: 'ok' }
|
||||||
|
});
|
||||||
|
assert.equal(submit.statusCode, 200);
|
||||||
|
assert.deepEqual(calls.at(-1)[1].photoUrls, ['https://api.txyundm.cn/uploads/cleaning/101.jpg']);
|
||||||
|
|
||||||
|
const stats = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/app-api/cleaning/stats',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(stats.statusCode, 200);
|
||||||
|
assert.equal(stats.json().data.pendingSettlementCents, 1200);
|
||||||
|
|
||||||
|
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall' });
|
||||||
|
assert.equal(unauthorized.statusCode, 401);
|
||||||
|
|
||||||
|
const forbidden = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/app-api/cleaning/tasks/hall',
|
||||||
|
headers: { authorization: `Bearer ${forbiddenToken}` }
|
||||||
|
});
|
||||||
|
assert.equal(forbidden.statusCode, 403);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
console.log('PASS: M08-B cleaning routes authenticate and forward cleaner task workflows.');
|
||||||
|
|
||||||
|
function task(status) {
|
||||||
|
return {
|
||||||
|
id: '101',
|
||||||
|
taskNo: 'CLN-20260625-0001',
|
||||||
|
storeId: '11',
|
||||||
|
storeName: 'Test Store',
|
||||||
|
roomId: '21',
|
||||||
|
roomName: 'A Room',
|
||||||
|
roomNo: 'A01',
|
||||||
|
orderId: '301',
|
||||||
|
orderNo: 'O301',
|
||||||
|
status,
|
||||||
|
rewardCents: 600,
|
||||||
|
photoUrls: []
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -81,6 +81,9 @@ const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.veri
|
|||||||
const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql');
|
const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql');
|
||||||
const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql');
|
const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql');
|
||||||
const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql');
|
const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql');
|
||||||
|
const cleaningUpSql = read('database/migrations/2026062525_m08b_cleaner_tasks.up.sql');
|
||||||
|
const cleaningDownSql = read('database/migrations/2026062525_m08b_cleaner_tasks.down.sql');
|
||||||
|
const cleaningVerifySql = read('database/migrations/2026062525_m08b_cleaner_tasks.verify.sql');
|
||||||
|
|
||||||
const coreTables = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -378,4 +381,21 @@ assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
|
|||||||
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
|
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
|
||||||
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
|
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
|
||||||
|
|
||||||
console.log('PASS: M01-B through M08-A migration contracts are present.');
|
for (const table of ['qipai_cleaning_tasks', 'qipai_cleaning_task_events']) {
|
||||||
|
assert.match(cleaningUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||||
|
assert.match(cleaningDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||||
|
assert.match(cleaningVerifySql, new RegExp(`'${table}'`));
|
||||||
|
}
|
||||||
|
for (const status of [
|
||||||
|
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
|
||||||
|
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
|
||||||
|
]) {
|
||||||
|
assert.match(cleaningUpSql, new RegExp(status));
|
||||||
|
}
|
||||||
|
assert.match(cleaningUpSql, /cleaner_user_id BIGINT UNSIGNED NULL/);
|
||||||
|
assert.match(cleaningUpSql, /photo_urls_json JSON NOT NULL/);
|
||||||
|
assert.match(cleaningUpSql, /uq_qipai_cleaning_task_order/);
|
||||||
|
assert.match(cleaningUpSql, /cleaning\.task\.write/);
|
||||||
|
assert.match(cleaningUpSql, /cleaning\.statistics\.read/);
|
||||||
|
|
||||||
|
console.log('PASS: M01-B through M08-B migration contracts are present.');
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
|
|||||||
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
|
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
|
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
|
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql$/);
|
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/);
|
||||||
|
assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql$/);
|
||||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DELETE rp FROM qipai_role_permissions rp
|
||||||
|
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE p.code IN ('cleaning.task.write', 'cleaning.statistics.read');
|
||||||
|
|
||||||
|
DELETE FROM qipai_permissions
|
||||||
|
WHERE code IN ('cleaning.task.write', 'cleaning.statistics.read');
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS qipai_cleaning_task_events;
|
||||||
|
DROP TABLE IF EXISTS qipai_cleaning_tasks;
|
||||||
|
|
||||||
|
DELETE FROM qipai_schema_migrations
|
||||||
|
WHERE version = '2026062525';
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS qipai_cleaning_tasks (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
store_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
room_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
order_id BIGINT UNSIGNED NULL,
|
||||||
|
task_no VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'WAITING',
|
||||||
|
cleaner_user_id BIGINT UNSIGNED NULL,
|
||||||
|
priority TINYINT UNSIGNED NOT NULL DEFAULT 5,
|
||||||
|
reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
requirement VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
photo_urls_json JSON NOT NULL,
|
||||||
|
reject_reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
claimed_at DATETIME(3) NULL,
|
||||||
|
started_at DATETIME(3) NULL,
|
||||||
|
submitted_at DATETIME(3) NULL,
|
||||||
|
completed_at DATETIME(3) NULL,
|
||||||
|
settled_at DATETIME(3) NULL,
|
||||||
|
cancelled_at DATETIME(3) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
deleted_at DATETIME(3) NULL,
|
||||||
|
CONSTRAINT fk_qipai_cleaning_tasks_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_tasks_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_tasks_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_tasks_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_tasks_cleaner FOREIGN KEY (cleaner_user_id) REFERENCES qipai_users(id),
|
||||||
|
CONSTRAINT chk_qipai_cleaning_task_status CHECK (
|
||||||
|
status IN (
|
||||||
|
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
|
||||||
|
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
UNIQUE KEY uq_qipai_cleaning_task_no (tenant_id, task_no),
|
||||||
|
UNIQUE KEY uq_qipai_cleaning_task_order (tenant_id, order_id),
|
||||||
|
KEY idx_qipai_cleaning_hall (tenant_id, store_id, status, priority, created_at),
|
||||||
|
KEY idx_qipai_cleaning_cleaner (tenant_id, cleaner_user_id, status, updated_at),
|
||||||
|
KEY idx_qipai_cleaning_room (tenant_id, room_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_cleaning_task_events (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
task_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
from_status VARCHAR(32) NULL,
|
||||||
|
to_status VARCHAR(32) NOT NULL,
|
||||||
|
action VARCHAR(32) NOT NULL,
|
||||||
|
actor_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
trace_id VARCHAR(128) NOT NULL,
|
||||||
|
note VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
metadata JSON NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_events_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_events_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id),
|
||||||
|
UNIQUE KEY uq_qipai_cleaning_event_trace (tenant_id, task_id, trace_id),
|
||||||
|
KEY idx_qipai_cleaning_events_task (tenant_id, task_id, id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
|
||||||
|
('cleaning.task.write', '处理保洁任务', 'cleaning'),
|
||||||
|
('cleaning.statistics.read', '查看保洁统计', 'cleaning');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
|
||||||
|
SELECT r.tenant_id, r.id, p.id
|
||||||
|
FROM qipai_roles r
|
||||||
|
INNER JOIN qipai_permissions p
|
||||||
|
ON (r.code = 'CLEANER' AND p.code IN (
|
||||||
|
'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'
|
||||||
|
))
|
||||||
|
OR (r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||||
|
AND p.code IN ('cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'))
|
||||||
|
WHERE r.status = 'ACTIVE' AND r.deleted_at IS NULL;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||||
|
VALUES ('2026062525', 'm08b_cleaner_tasks');
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name IN ('qipai_cleaning_tasks', 'qipai_cleaning_task_events');
|
||||||
|
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qipai_cleaning_tasks'
|
||||||
|
AND column_name IN (
|
||||||
|
'task_no', 'status', 'cleaner_user_id', 'reward_cents',
|
||||||
|
'photo_urls_json', 'reject_reason', 'started_at', 'submitted_at'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT index_name
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qipai_cleaning_tasks'
|
||||||
|
AND index_name IN (
|
||||||
|
'uq_qipai_cleaning_task_no', 'uq_qipai_cleaning_task_order',
|
||||||
|
'idx_qipai_cleaning_hall', 'idx_qipai_cleaning_cleaner'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT code
|
||||||
|
FROM qipai_permissions
|
||||||
|
WHERE code IN ('cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read');
|
||||||
|
|
||||||
|
SELECT version
|
||||||
|
FROM qipai_schema_migrations
|
||||||
|
WHERE version = '2026062525';
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"pages/profile/index",
|
"pages/profile/index",
|
||||||
"pages/benefits/index",
|
"pages/benefits/index",
|
||||||
"pages/recharge/index",
|
"pages/recharge/index",
|
||||||
|
"pages/cleaner/tasks",
|
||||||
"pages/logs/logs"
|
"pages/logs/logs"
|
||||||
],
|
],
|
||||||
"window": {
|
"window": {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
const { request, ensureLogin, cents } = require('../../utils/api.js')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
|
activeTab: 'hall',
|
||||||
|
hallTasks: [],
|
||||||
|
myTasks: [],
|
||||||
|
stats: { byStatus: {}, pendingSettlementText: cents(0) },
|
||||||
|
submitPhotoText: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.refreshAll()
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshAll() {
|
||||||
|
this.setData({ loading: true, errorMessage: '' })
|
||||||
|
try {
|
||||||
|
await ensureLogin()
|
||||||
|
const [hall, mine, stats] = await Promise.all([
|
||||||
|
request('/cleaning/tasks/hall?page=1&pageSize=20'),
|
||||||
|
request('/cleaning/tasks/mine?page=1&pageSize=20'),
|
||||||
|
request('/cleaning/stats'),
|
||||||
|
])
|
||||||
|
this.setData({
|
||||||
|
hallTasks: (hall.data.items || []).map(formatTask),
|
||||||
|
myTasks: (mine.data.items || []).map(formatTask),
|
||||||
|
stats: formatStats(stats.data),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ errorMessage: error.message || '保洁任务加载失败' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
switchTab(event) {
|
||||||
|
const tab = event.currentTarget.dataset.tab
|
||||||
|
if (!tab || tab === this.data.activeTab) return
|
||||||
|
this.setData({ activeTab: tab })
|
||||||
|
},
|
||||||
|
|
||||||
|
async claimTask(event) {
|
||||||
|
await this.mutateTask(event.currentTarget.dataset.taskId, 'claim')
|
||||||
|
},
|
||||||
|
|
||||||
|
async startTask(event) {
|
||||||
|
await this.mutateTask(event.currentTarget.dataset.taskId, 'start')
|
||||||
|
},
|
||||||
|
|
||||||
|
onPhotoTextInput(event) {
|
||||||
|
this.setData({ submitPhotoText: event.detail.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitTask(event) {
|
||||||
|
const photoUrls = this.data.submitPhotoText
|
||||||
|
.split('\n')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 9)
|
||||||
|
await this.mutateTask(event.currentTarget.dataset.taskId, 'submit', { photoUrls })
|
||||||
|
this.setData({ submitPhotoText: '' })
|
||||||
|
},
|
||||||
|
|
||||||
|
async mutateTask(taskId, action, data = {}) {
|
||||||
|
if (!taskId) return
|
||||||
|
this.setData({ loading: true, errorMessage: '' })
|
||||||
|
try {
|
||||||
|
await ensureLogin()
|
||||||
|
await request(`/cleaning/tasks/${encodeURIComponent(taskId)}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
await this.refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ errorMessage: error.message || '保洁任务处理失败' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTask(task) {
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
rewardText: cents(task.rewardCents),
|
||||||
|
timeText: formatDate(task.updatedAt || task.createdAt),
|
||||||
|
canClaim: task.status === 'WAITING',
|
||||||
|
canStart: task.status === 'CLAIMED',
|
||||||
|
canSubmit: task.status === 'STARTED',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStats(stats) {
|
||||||
|
return {
|
||||||
|
byStatus: stats.byStatus || {},
|
||||||
|
pendingSettlementText: cents(stats.pendingSettlementCents),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
const pad = (input) => String(input).padStart(2, '0')
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "保洁任务"
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<scroll-view class="scrollarea" scroll-y type="list">
|
||||||
|
<view class="page">
|
||||||
|
<view class="header">
|
||||||
|
<view>
|
||||||
|
<view class="title">保洁任务</view>
|
||||||
|
<view class="muted">待结算 {{stats.pendingSettlementText}}</view>
|
||||||
|
</view>
|
||||||
|
<button size="mini" loading="{{loading}}" bindtap="refreshAll">刷新</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tabs">
|
||||||
|
<button class="{{activeTab === 'hall' ? 'tab active' : 'tab'}}" data-tab="hall" bindtap="switchTab">大厅</button>
|
||||||
|
<button class="{{activeTab === 'mine' ? 'tab active' : 'tab'}}" data-tab="mine" bindtap="switchTab">我的</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="stats">
|
||||||
|
<text>已抢 {{stats.byStatus.CLAIMED || 0}}</text>
|
||||||
|
<text>进行 {{stats.byStatus.STARTED || 0}}</text>
|
||||||
|
<text>待验 {{stats.byStatus.SUBMITTED || 0}}</text>
|
||||||
|
<text>完成 {{stats.byStatus.COMPLETED || 0}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||||
|
|
||||||
|
<block wx:if="{{activeTab === 'hall'}}">
|
||||||
|
<view wx:if="{{!loading && hallTasks.length === 0}}" class="empty">暂无可接任务</view>
|
||||||
|
<view wx:for="{{hallTasks}}" wx:key="id" class="task-card">
|
||||||
|
<view class="task-title">{{item.storeName}} · {{item.roomName}}</view>
|
||||||
|
<view class="muted">任务号 {{item.taskNo}}</view>
|
||||||
|
<view class="muted">{{item.requirement || '常规保洁'}}</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="status">{{item.status}}</text>
|
||||||
|
<text class="amount">{{item.rewardText}}</text>
|
||||||
|
</view>
|
||||||
|
<button wx:if="{{item.canClaim}}" data-task-id="{{item.id}}" bindtap="claimTask">接单</button>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block wx:if="{{activeTab === 'mine'}}">
|
||||||
|
<view wx:if="{{!loading && myTasks.length === 0}}" class="empty">暂无我的任务</view>
|
||||||
|
<view wx:for="{{myTasks}}" wx:key="id" class="task-card">
|
||||||
|
<view class="task-title">{{item.storeName}} · {{item.roomName}}</view>
|
||||||
|
<view class="muted">任务号 {{item.taskNo}}</view>
|
||||||
|
<view class="muted">{{item.timeText}}</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="status">{{item.status}}</text>
|
||||||
|
<text class="amount">{{item.rewardText}}</text>
|
||||||
|
</view>
|
||||||
|
<button wx:if="{{item.canStart}}" data-task-id="{{item.id}}" bindtap="startTask">开始</button>
|
||||||
|
<view wx:if="{{item.canSubmit}}" class="submit-box">
|
||||||
|
<textarea value="{{submitPhotoText}}" bindinput="onPhotoTextInput" placeholder="每行一个照片 URL" />
|
||||||
|
<button data-task-id="{{item.id}}" bindtap="submitTask">提交验收</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
.scrollarea {
|
||||||
|
height: 100vh;
|
||||||
|
background: #f5f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.row,
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin: 28rpx 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
border: 1rpx solid #d0d5dd;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #344054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border-color: #1f6feb;
|
||||||
|
background: #eaf2ff;
|
||||||
|
color: #1f6feb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
margin: 18rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: #1f6feb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
color: #b42318;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-box {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 144rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border: 1rpx solid #d0d5dd;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 20rpx 0;
|
||||||
|
color: #c73535;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 80rpx 0;
|
||||||
|
color: #888888;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ for (const page of [
|
|||||||
'pages/orders/detail',
|
'pages/orders/detail',
|
||||||
'pages/profile/index',
|
'pages/profile/index',
|
||||||
'pages/benefits/index',
|
'pages/benefits/index',
|
||||||
'pages/recharge/index'
|
'pages/recharge/index',
|
||||||
|
'pages/cleaner/tasks'
|
||||||
]) {
|
]) {
|
||||||
assert.ok(appJson.pages.includes(page), `${page} must be registered`);
|
assert.ok(appJson.pages.includes(page), `${page} must be registered`);
|
||||||
}
|
}
|
||||||
@@ -116,4 +117,22 @@ for (const pattern of [
|
|||||||
assert.match(recharge, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
assert.match(recharge, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('PASS: M08-A miniapp customer pages use fixed domain and real app-api calls.');
|
const cleaner = read('miniapp/pages/cleaner/tasks.js')
|
||||||
|
+ read('miniapp/pages/cleaner/tasks.wxml')
|
||||||
|
+ read('miniapp/pages/cleaner/tasks.json');
|
||||||
|
for (const pattern of [
|
||||||
|
'/cleaning/tasks/hall',
|
||||||
|
'/cleaning/tasks/mine',
|
||||||
|
'/cleaning/stats',
|
||||||
|
'/cleaning/tasks/${encodeURIComponent(taskId)}/${action}',
|
||||||
|
'claimTask',
|
||||||
|
'startTask',
|
||||||
|
'submitTask',
|
||||||
|
'photoUrls',
|
||||||
|
'pendingSettlementText',
|
||||||
|
'保洁任务'
|
||||||
|
]) {
|
||||||
|
assert.match(cleaner, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('PASS: M08-A/M08-B miniapp customer and cleaner pages use fixed domain and real app-api calls.');
|
||||||
|
|||||||
Reference in New Issue
Block a user