feat(M09-B): 完成清洁规则与照片验收闭环

This commit is contained in:
Codex
2026-08-10 21:49:07 +08:00
parent b507e87999
commit bc7cb8fab9
24 changed files with 1650 additions and 81 deletions
+469 -29
View File
@@ -9,6 +9,8 @@ export const cleaningTaskStatuses = [
export type CleaningTaskStatus = typeof cleaningTaskStatuses[number];
export type CleaningSettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
export type CleaningSettlementPayoutState = 'NONE' | 'SUCCESS' | 'FAIL' | 'PROCESSING' | 'WAIT_USER_CONFIRM';
export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM';
export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE';
export class CleaningTaskError extends Error {
constructor(public readonly code: string) { super(code); }
@@ -36,8 +38,16 @@ interface CleaningTaskRow extends RowDataPacket {
priority: number;
rewardCents: number;
requirement: string;
cleaningTemplateId: string | null;
cleaningTemplateVersion: number;
photoRequired: number | boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
photoUrlsJson: string | string[] | null;
rejectReason: string;
reworkCount: number;
photoRevision: number;
claimedAt: Date | null;
startedAt: Date | null;
submittedAt: Date | null;
@@ -68,10 +78,55 @@ interface CleaningTaskEventRow extends RowDataPacket {
note: string;
createdAt: Date;
}
interface CleaningTaskSubmissionRow extends RowDataPacket {
id: string;
taskId: string;
revision: number;
status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED';
photoUrlsJson: string | string[];
note: string;
rejectReason: string;
submittedBy: string;
reviewedBy: string | null;
submittedAt: Date;
reviewedAt: Date | null;
}
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 }
interface CurrentTaskRuleRow extends CurrentStatusRow {
storeId: string;
photoRequired: number | boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
reworkCount: number;
}
interface CleaningTemplateRow extends RowDataPacket {
id: string;
scopeKey: string;
scopeType: CleaningTemplateScope;
storeId: string | null;
roomId: string | null;
name: string;
requirement: string;
photoRequired: number | boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
version: number;
status: 'ACTIVE' | 'DISABLED';
updatedAt: Date;
}
interface PhotoOwnershipRow extends RowDataPacket {
id: string;
publicUrl: string;
}
interface ExpiredPhotoRow extends RowDataPacket {
id: string;
storagePath: string;
}
interface ReclaimRow extends CurrentStatusRow { id: string }
interface FinishedOrderRow extends RowDataPacket {
id: string;
@@ -167,6 +222,72 @@ interface ManagerDailyTrendRow extends RowDataPacket {
export class CleaningTaskRepository {
constructor(private readonly pool: MySqlPool) {}
async listTemplates(input: CleaningActor) {
this.assertCleaner(input.access, 'read');
const [rows] = await this.pool.execute<CleaningTemplateRow[]>(
`SELECT t.id, t.scope_key AS scopeKey, t.scope_type AS scopeType,
t.store_id AS storeId, t.room_id AS roomId, t.name, t.requirement,
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
t.version, t.status, t.updated_at AS updatedAt
FROM qipai_cleaning_templates t
WHERE t.tenant_id = ?
AND (t.scope_type = 'TENANT' OR ${storeScopeSql(input.access, 't.store_id')})
ORDER BY FIELD(t.scope_type, 'TENANT', 'STORE', 'ROOM'), t.scope_key`,
[input.tenantId]
);
return rows.map(publicTemplate);
}
async upsertTemplate(input: CleaningActor & {
scopeType: CleaningTemplateScope;
scopeId?: string;
name: string;
requirement: string;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
status: 'ACTIVE' | 'DISABLED';
}) {
this.assertCleaner(input.access, 'write');
if (input.maxPhotoCount < 1 || input.maxPhotoCount > 9
|| input.minPhotoCount < 0 || input.minPhotoCount > input.maxPhotoCount
|| (input.photoRequired && input.minPhotoCount < 1)
|| (!input.photoRequired && input.minPhotoCount !== 0)) {
throw new CleaningTaskError('CLEANING_TEMPLATE_PHOTO_RULE_INVALID');
}
const scope = await this.resolveTemplateScope(input, input.scopeType, input.scopeId);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_cleaning_templates
(tenant_id, scope_key, scope_type, store_id, room_id, name, requirement,
photo_required, min_photo_count, max_photo_count, exempt_policy, status,
created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
id = LAST_INSERT_ID(id), name = VALUES(name), requirement = VALUES(requirement),
photo_required = VALUES(photo_required), min_photo_count = VALUES(min_photo_count),
max_photo_count = VALUES(max_photo_count), exempt_policy = VALUES(exempt_policy),
status = VALUES(status), updated_by = VALUES(updated_by), version = version + 1`,
[input.tenantId, scope.scopeKey, input.scopeType, scope.storeId, scope.roomId,
input.name.slice(0, 128), input.requirement.slice(0, 512), input.photoRequired ? 1 : 0,
input.minPhotoCount, input.maxPhotoCount, input.exemptPolicy, input.status,
input.userId, input.userId]
);
const templateId = String(result.insertId);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id, trace_id, metadata)
VALUES (?, 'USER', ?, 'CLEANING_TEMPLATE_UPSERT', 'CLEANING_TEMPLATE', ?, ?,
JSON_OBJECT('scopeType', ?, 'scopeKey', ?, 'appliesToExistingTasks', FALSE))`,
[input.tenantId, input.userId, templateId, input.traceId,
input.scopeType, scope.scopeKey]
);
return this.getTemplate(connection, input.tenantId, templateId);
});
}
async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
this.assertCleaner(input.access, 'read');
const status = input.status ?? 'WAITING';
@@ -263,10 +384,9 @@ export class CleaningTaskRepository {
}
async rework(input: CleaningActor & { taskId: string }) {
return this.moveMine(input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', {
photo_urls_json: JSON.stringify([]),
reject_reason: ''
});
return this.moveMine(
input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', { incrementRework: true }
);
}
async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
@@ -289,7 +409,8 @@ export class CleaningTaskRepository {
);
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
SET rework_count = rework_count + IF(status = 'REJECTED', 1, 0),
status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
started_at = NULL, submitted_at = NULL, completed_at = NULL, settled_at = NULL,
cancelled_at = NULL, photo_urls_json = JSON_ARRAY(), reject_reason = ''
WHERE tenant_id = ? AND id = ?`,
@@ -304,9 +425,28 @@ export class CleaningTaskRepository {
}
async complete(input: CleaningActor & { taskId: string; note?: string }) {
return this.moveManaged(
input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''
);
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
await this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'COMPLETED', completed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`,
[input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_task_submissions
SET status = 'ACCEPTED', reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND task_id = ? AND status = 'SUBMITTED'`,
[input.userId, input.tenantId, input.taskId]
);
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
await this.recordEventWithConnection(
connection, input, input.taskId, 'SUBMITTED', 'COMPLETED', 'COMPLETE', input.note ?? ''
);
});
return this.getTask(input, input.taskId);
}
async reject(input: CleaningActor & { taskId: string; reason: string }) {
@@ -320,6 +460,12 @@ export class CleaningTaskRepository {
[input.reason.slice(0, 512), input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_task_submissions
SET status = 'REJECTED', reject_reason = ?, reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND task_id = ? AND status = 'SUBMITTED'`,
[input.reason.slice(0, 512), input.userId, input.tenantId, input.taskId]
);
await this.recordEventWithConnection(
connection, input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason
);
@@ -331,8 +477,11 @@ export class CleaningTaskRepository {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
await this.transaction(async (connection) => {
const [currentRows] = await connection.execute<CurrentStatusRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId
const [currentRows] = await connection.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
rework_count AS reworkCount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
FOR UPDATE`,
@@ -340,6 +489,12 @@ export class CleaningTaskRepository {
);
const current = currentRows[0];
if (!current) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
const allowedStatuses = current.exemptPolicy === 'ANY_ACTIVE'
? ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED']
: current.exemptPolicy === 'BEFORE_START' ? ['WAITING', 'CLAIMED'] : [];
if (!allowedStatuses.includes(current.status)) {
throw new CleaningTaskError('CLEANING_EXEMPT_POLICY_DENIED');
}
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'EXEMPT',
@@ -348,10 +503,8 @@ export class CleaningTaskRepository {
reject_reason = '',
completed_at = NULL,
settled_at = NULL
WHERE tenant_id = ? AND id = ?
AND status IN ('WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED')
AND deleted_at IS NULL`,
[input.tenantId, input.taskId]
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
[input.tenantId, input.taskId, current.status]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await connection.execute(
@@ -395,6 +548,24 @@ export class CleaningTaskRepository {
return rows.map(publicTaskEvent);
}
async listSubmissions(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'read');
await this.assertStoreVisible(input, input.taskId);
const [rows] = await this.pool.execute<CleaningTaskSubmissionRow[]>(
`SELECT s.id, s.task_id AS taskId, s.revision, s.status,
s.photo_urls_json AS photoUrlsJson, s.note,
s.reject_reason AS rejectReason, s.submitted_by AS submittedBy,
s.reviewed_by AS reviewedBy, s.submitted_at AS submittedAt,
s.reviewed_at AS reviewedAt
FROM qipai_cleaning_task_submissions s
WHERE s.tenant_id = ? AND s.task_id = ?
ORDER BY s.revision DESC, s.id DESC
LIMIT 20`,
[input.tenantId, input.taskId]
);
return rows.map(publicTaskSubmission);
}
async addMember(input: CleaningActor & {
taskId: string; cleanerUserId: string; rewardCents: number; note?: string;
}) {
@@ -825,26 +996,68 @@ export class CleaningTaskRepository {
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
this.assertCleaner(input.access, 'write');
if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED');
await this.transaction(async (connection) => {
const [rows] = await connection.execute<CurrentStatusRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId
const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
rework_count AS reworkCount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.taskId, input.userId]
);
const current = rows[0];
if (!current || !['STARTED', 'REJECTED'].includes(current.status)) {
if (!current || current.status !== 'STARTED') {
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
}
const photoUrls = [...new Set(input.photoUrls)];
if (photoUrls.length !== input.photoUrls.length
|| photoUrls.length < Number(current.minPhotoCount)
|| photoUrls.length > Number(current.maxPhotoCount)
|| (current.photoRequired && photoUrls.length === 0)) {
throw new CleaningTaskError('CLEANING_PHOTO_COUNT_INVALID');
}
let ownedPhotos: PhotoOwnershipRow[] = [];
if (photoUrls.length > 0) {
const placeholders = photoUrls.map(() => '?').join(',');
const [photoRows] = await connection.execute<PhotoOwnershipRow[]>(
`SELECT id, public_url AS publicUrl
FROM qipai_cleaning_task_photos
WHERE tenant_id = ? AND task_id = ? AND uploaded_by = ?
AND status = 'PENDING' AND deleted_at IS NULL
AND public_url IN (${placeholders})
FOR UPDATE`,
[input.tenantId, input.taskId, input.userId, ...photoUrls]
);
ownedPhotos = photoRows;
if (ownedPhotos.length !== photoUrls.length) {
throw new CleaningTaskError('CLEANING_PHOTO_OWNERSHIP_INVALID');
}
}
const revision = Number(current.reworkCount) + 1;
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3),
photo_urls_json = ?, reject_reason = ''
WHERE tenant_id = ? AND id = ?`,
[JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId]
[JSON.stringify(photoUrls), input.tenantId, input.taskId]
);
await connection.execute(
`INSERT INTO qipai_cleaning_task_submissions
(tenant_id, task_id, revision, status, photo_urls_json, note, submitted_by)
VALUES (?, ?, ?, 'SUBMITTED', ?, ?, ?)`,
[input.tenantId, input.taskId, revision, JSON.stringify(photoUrls),
(input.note ?? '').slice(0, 512), input.userId]
);
if (ownedPhotos.length > 0) {
await connection.execute(
`UPDATE qipai_cleaning_task_photos
SET status = 'ATTACHED', attached_revision = ?, retention_until = '9999-12-31 23:59:59.999'
WHERE tenant_id = ? AND task_id = ? AND id IN (${ownedPhotos.map(() => '?').join(',')})`,
[revision, input.tenantId, input.taskId, ...ownedPhotos.map((photo) => photo.id)]
);
}
await this.recordEventWithConnection(
connection, input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''
);
@@ -854,14 +1067,106 @@ export class CleaningTaskRepository {
async assertCanUploadPhoto(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'write');
const [rows] = await this.pool.execute<CurrentStatusRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId
const [rows] = await this.pool.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
rework_count AS reworkCount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status IN ('STARTED', 'REJECTED')
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = 'STARTED'
AND deleted_at IS NULL`,
[input.tenantId, input.taskId, input.userId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
return { storeId: String(rows[0].storeId), maxPhotoCount: Number(rows[0].maxPhotoCount) };
}
async recordPhotoUpload(input: CleaningActor & { taskId: string; image: {
storagePath: string;
publicUrl: string;
mimeType: string;
byteSize: number;
width: number;
height: number;
checksumSha256: string;
} }) {
this.assertCleaner(input.access, 'write');
return this.transaction(async (connection) => {
const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
rework_count AS reworkCount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ?
AND status = 'STARTED' AND deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.taskId, input.userId]
);
const task = rows[0];
if (!task) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
const [counts] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_task_photos
WHERE tenant_id = ? AND task_id = ? AND uploaded_by = ?
AND status = 'PENDING' AND deleted_at IS NULL`,
[input.tenantId, input.taskId, input.userId]
);
if (Number(counts[0]?.total ?? 0) >= Number(task.maxPhotoCount)) {
throw new CleaningTaskError('CLEANING_PHOTO_COUNT_INVALID');
}
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_cleaning_task_photos
(tenant_id, task_id, store_id, uploaded_by, storage_path, public_url,
mime_type, byte_size, width, height, checksum_sha256, status, retention_until)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING',
TIMESTAMPADD(DAY, 7, UTC_TIMESTAMP(3)))`,
[input.tenantId, input.taskId, task.storeId, input.userId,
input.image.storagePath, input.image.publicUrl, input.image.mimeType,
input.image.byteSize, input.image.width, input.image.height, input.image.checksumSha256]
);
return { id: String(result.insertId), ...input.image };
});
}
async claimExpiredPhotoUploads(input: CleaningActor & { limit: number }) {
this.assertCleaner(input.access, 'write');
const safeLimit = Math.max(1, Math.min(100, Math.trunc(input.limit)));
return this.transaction(async (connection) => {
const [rows] = await connection.execute<ExpiredPhotoRow[]>(
`SELECT p.id, p.storage_path AS storagePath
FROM qipai_cleaning_task_photos p
WHERE p.tenant_id = ? AND p.status IN ('PENDING', 'ORPHANED')
AND p.retention_until <= UTC_TIMESTAMP(3) AND p.deleted_at IS NULL
AND ${storeScopeSql(input.access, 'p.store_id')}
ORDER BY p.retention_until ASC, p.id ASC
LIMIT ${safeLimit} FOR UPDATE SKIP LOCKED`,
[input.tenantId]
);
if (rows.length > 0) {
await connection.execute(
`UPDATE qipai_cleaning_task_photos
SET status = 'ORPHANED', retention_until = TIMESTAMPADD(MINUTE, 5, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id IN (${rows.map(() => '?').join(',')})`,
[input.tenantId, ...rows.map((row) => row.id)]
);
}
return rows.map((row) => ({ id: String(row.id), storagePath: row.storagePath }));
});
}
async markPhotoUploadsDeleted(input: CleaningActor & { photoIds: string[] }) {
this.assertCleaner(input.access, 'write');
if (input.photoIds.length === 0) return { deleted: 0 };
const uniqueIds = [...new Set(input.photoIds)];
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_task_photos p
SET p.deleted_at = UTC_TIMESTAMP(3), p.retention_until = UTC_TIMESTAMP(3)
WHERE p.tenant_id = ? AND p.status = 'ORPHANED' AND p.deleted_at IS NULL
AND p.id IN (${uniqueIds.map(() => '?').join(',')})
AND ${storeScopeSql(input.access, 'p.store_id')}`,
[input.tenantId, ...uniqueIds]
);
return { deleted: result.affectedRows };
}
async createForFinishedOrder(
@@ -877,23 +1182,45 @@ export class CleaningTaskRepository {
);
const order = orders[0];
if (!order) throw new CleaningTaskError('CLEANING_ORDER_NOT_FINISHED');
const [templates] = await connection.execute<CleaningTemplateRow[]>(
`SELECT id, scope_key AS scopeKey, scope_type AS scopeType,
store_id AS storeId, room_id AS roomId, name, requirement,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
version, status, updated_at AS updatedAt
FROM qipai_cleaning_templates
WHERE tenant_id = ? AND status = 'ACTIVE'
AND ((scope_type = 'ROOM' AND room_id = ?)
OR (scope_type = 'STORE' AND store_id = ?)
OR scope_type = 'TENANT')
ORDER BY FIELD(scope_type, 'ROOM', 'STORE', 'TENANT')
LIMIT 1`,
[input.tenantId, order.roomId, order.storeId]
);
const template = templates[0];
const taskNo = `CLN-${order.orderNo}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_cleaning_tasks
(tenant_id, store_id, room_id, order_id, task_no, status, priority,
reward_cents, requirement, photo_urls_json)
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, '订单结束后保洁', JSON_ARRAY())`,
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo]
reward_cents, requirement, cleaning_template_id, cleaning_template_version,
photo_required, min_photo_count, max_photo_count, exempt_policy, photo_urls_json)
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, ?, ?, ?, ?, ?, ?, ?, JSON_ARRAY())`,
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo,
template?.requirement || '订单结束后清洁', template?.id ?? null,
Number(template?.version ?? 0), template ? (template.photoRequired ? 1 : 0) : 1,
Number(template?.minPhotoCount ?? 1), Number(template?.maxPhotoCount ?? 9),
template?.exemptPolicy ?? 'ANY_ACTIVE']
);
if (result.affectedRows === 1) {
await connection.execute(
`INSERT IGNORE INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
SELECT tenant_id, id, NULL, 'WAITING', 'AUTO_CREATE', ?, ?, '订单结束自动创建保洁任务',
JSON_OBJECT('orderId', ?)
JSON_OBJECT('orderId', ?, 'cleaningTemplateId', ?, 'cleaningTemplateVersion', ?)
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND order_id = ?`,
[input.actorId, input.traceId, input.orderId, input.tenantId, input.orderId]
[input.actorId, input.traceId, input.orderId, template?.id ?? null,
Number(template?.version ?? 0), input.tenantId, input.orderId]
);
}
}
@@ -1106,7 +1433,14 @@ export class CleaningTaskRepository {
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.requirement, t.cleaning_template_id AS cleaningTemplateId,
t.cleaning_template_version AS cleaningTemplateVersion,
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
t.rework_count AS reworkCount,
COALESCE((SELECT MAX(sub.revision) FROM qipai_cleaning_task_submissions sub
WHERE sub.tenant_id = t.tenant_id AND sub.task_id = t.id), 0) AS photoRevision,
t.claimed_at AS claimedAt, t.started_at AS startedAt,
t.submitted_at AS submittedAt, t.completed_at AS completedAt,
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
@@ -1175,7 +1509,7 @@ export class CleaningTaskRepository {
action: string,
timestampColumn: 'started_at' | 'submitted_at',
note: string,
extra?: { photo_urls_json?: string; reject_reason?: string }
extra?: { photo_urls_json?: string; reject_reason?: string; incrementRework?: boolean }
) {
this.assertCleaner(input.access, 'write');
const extraAssignments: string[] = [];
@@ -1188,6 +1522,7 @@ export class CleaningTaskRepository {
extraAssignments.push('reject_reason = ?');
extraParams.push(extra.reject_reason);
}
if (extra?.incrementRework) extraAssignments.push('rework_count = rework_count + 1');
const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : '';
await this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
@@ -1280,6 +1615,61 @@ export class CleaningTaskRepository {
);
}
private async getTemplate(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
templateId: string
) {
const [rows] = await connection.execute<CleaningTemplateRow[]>(
`SELECT id, scope_key AS scopeKey, scope_type AS scopeType,
store_id AS storeId, room_id AS roomId, name, requirement,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
version, status, updated_at AS updatedAt
FROM qipai_cleaning_templates
WHERE tenant_id = ? AND id = ?`,
[tenantId, templateId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_TEMPLATE_NOT_FOUND');
return publicTemplate(rows[0]);
}
private async resolveTemplateScope(
input: CleaningActor,
scopeType: CleaningTemplateScope,
scopeId?: string
) {
if (scopeType === 'TENANT') {
if (scopeId || (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN'))) {
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
}
return { scopeKey: 'TENANT', storeId: null, roomId: null };
}
if (!scopeId) throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_INVALID');
if (scopeType === 'STORE') {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, scopeId]
);
if (!rows[0] || !canAccessStore(input.access, scopeId)) {
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
}
return { scopeKey: `STORE:${scopeId}`, storeId: scopeId, roomId: null };
}
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT store_id AS storeId FROM qipai_rooms
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, scopeId]
);
const storeId = rows[0]?.storeId ? String(rows[0].storeId) : '';
if (!storeId || !canAccessStore(input.access, storeId)) {
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
}
return { scopeKey: `ROOM:${scopeId}`, storeId, roomId: scopeId };
}
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[]>(
@@ -1417,6 +1807,12 @@ export class CleaningTaskRepository {
}
}
function canAccessStore(access: AccessProfile, storeId: string) {
return access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| access.storeIds.includes(storeId);
}
function storeScopeSql(access: AccessProfile, storeExpression: string) {
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
return '1 = 1';
@@ -1441,8 +1837,16 @@ function publicTask(row: CleaningTaskRow) {
priority: Number(row.priority),
rewardCents: Number(row.rewardCents),
requirement: row.requirement,
cleaningTemplateId: row.cleaningTemplateId === null ? null : String(row.cleaningTemplateId),
cleaningTemplateVersion: Number(row.cleaningTemplateVersion),
photoRequired: Boolean(row.photoRequired),
minPhotoCount: Number(row.minPhotoCount),
maxPhotoCount: Number(row.maxPhotoCount),
exemptPolicy: row.exemptPolicy,
photoUrls: parseJsonArray(row.photoUrlsJson),
rejectReason: row.rejectReason,
reworkCount: Number(row.reworkCount),
photoRevision: Number(row.photoRevision),
claimedAt: row.claimedAt,
startedAt: row.startedAt,
submittedAt: row.submittedAt,
@@ -1453,6 +1857,26 @@ function publicTask(row: CleaningTaskRow) {
};
}
function publicTemplate(row: CleaningTemplateRow) {
return {
id: String(row.id),
scopeKey: row.scopeKey,
scopeType: row.scopeType,
storeId: row.storeId === null ? null : String(row.storeId),
roomId: row.roomId === null ? null : String(row.roomId),
name: row.name,
requirement: row.requirement,
photoRequired: Boolean(row.photoRequired),
minPhotoCount: Number(row.minPhotoCount),
maxPhotoCount: Number(row.maxPhotoCount),
exemptPolicy: row.exemptPolicy,
version: Number(row.version),
status: row.status,
updatedAt: row.updatedAt,
appliesToExistingTasks: false
};
}
function publicTaskMember(row: CleaningTaskMemberRow) {
return {
id: String(row.id),
@@ -1481,6 +1905,22 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
};
}
function publicTaskSubmission(row: CleaningTaskSubmissionRow) {
return {
id: String(row.id),
taskId: String(row.taskId),
revision: Number(row.revision),
status: row.status,
photoUrls: parseJsonArray(row.photoUrlsJson),
note: row.note,
rejectReason: row.rejectReason,
submittedBy: String(row.submittedBy),
reviewedBy: row.reviewedBy === null ? null : String(row.reviewedBy),
submittedAt: row.submittedAt,
reviewedAt: row.reviewedAt
};
}
function publicSettlement(row: SettlementRow) {
return {
id: String(row.id),
+31 -3
View File
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { extname, resolve, sep } from 'node:path';
import sharp from 'sharp';
@@ -33,18 +33,33 @@ export class MediaStorage {
if (input.body.length === 0 || input.body.length > 8 * 1024 * 1024) {
throw new MediaValidationError('IMAGE_SIZE_INVALID');
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.contentType)) {
const declaredFormat = {
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp'
}[input.contentType];
if (!declaredFormat) {
throw new MediaValidationError('IMAGE_TYPE_INVALID');
}
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(extname(input.originalName).toLowerCase())) {
const declaredExtension = {
'.jpg': 'jpeg',
'.jpeg': 'jpeg',
'.png': 'png',
'.webp': 'webp'
}[extname(input.originalName).toLowerCase()];
if (!declaredExtension) {
throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
}
if (declaredExtension !== declaredFormat) {
throw new MediaValidationError('IMAGE_TYPE_MISMATCH');
}
let result: Buffer;
let metadata: sharp.Metadata;
try {
const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 });
metadata = await source.metadata();
if (!metadata.width || !metadata.height) throw new Error('missing dimensions');
if (metadata.format !== declaredFormat) throw new Error('declared image type mismatch');
result = await source
.rotate()
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
@@ -74,4 +89,17 @@ export class MediaStorage {
checksumSha256: createHash('sha256').update(result).digest('hex')
};
}
async deleteImage(storagePath: string): Promise<void> {
const safeRoot = resolve(this.root);
const target = resolve(safeRoot, storagePath);
if (target === safeRoot || !target.startsWith(`${safeRoot}${sep}`)) {
throw new MediaValidationError('IMAGE_PATH_INVALID');
}
try {
await unlink(target);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
}
+5 -2
View File
@@ -53,7 +53,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql'
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
'database/migrations/2026081005_m09b_cleaning_rules.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -88,9 +89,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql'
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql'
],
down: [
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
@@ -60,12 +60,17 @@ export class OrderDeviceAutomationService {
}
if (payload.event === 'ORDER_ROOM_CHANGED') {
if (payload.previousRoomId && payload.previousRoomId !== order.roomId) {
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
if (!await this.hasActiveRoomOrder(order.tenantId, payload.previousRoomId, order.id)) {
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
}
}
await this.startOrderDevices(order, payload.traceId);
return { orderId: order.id, action: 'ROOM_CHANGED' };
}
if (await this.hasActiveRoomOrder(order.tenantId, order.roomId, order.id)) {
return { orderId: order.id, skipped: true, reason: 'ROOM_HAS_ACTIVE_ORDER' };
}
await this.cancelRoomDevices(order, payload.traceId, order.roomId);
return { orderId: order.id, action: 'CANCELLED' };
}
@@ -112,6 +117,17 @@ export class OrderDeviceAutomationService {
};
}
private async hasActiveRoomOrder(tenantId: string, roomId: string, excludedOrderId: string) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_orders
WHERE tenant_id = ? AND room_id = ? AND id <> ?
AND status IN ('PAID', 'RESERVED', 'IN_PROGRESS') AND deleted_at IS NULL
LIMIT 1`,
[tenantId, roomId, excludedOrderId]
);
return Boolean(rows[0]);
}
private async loadOrder(tenantId: string, orderId: string) {
const [rows] = await this.pool.execute<OrderRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
+110 -6
View File
@@ -52,6 +52,26 @@ const memberRemoveSchema = z.object({
const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict();
const exemptSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const cleaningTemplateSchema = z.object({
scopeType: z.enum(['TENANT', 'STORE', 'ROOM']),
scopeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
name: z.string().trim().min(1).max(128),
requirement: z.string().trim().max(512).default(''),
photoRequired: z.boolean().default(true),
minPhotoCount: z.coerce.number().int().min(0).max(9).default(1),
maxPhotoCount: z.coerce.number().int().min(1).max(9).default(9),
exemptPolicy: z.enum(['DISABLED', 'BEFORE_START', 'ANY_ACTIVE']).default('ANY_ACTIVE'),
status: z.enum(['ACTIVE', 'DISABLED']).default('ACTIVE')
}).strict().superRefine((value, context) => {
if ((value.scopeType === 'TENANT') !== !value.scopeId) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['scopeId'], message: 'scope mismatch' });
}
if (value.minPhotoCount > value.maxPhotoCount
|| (value.photoRequired && value.minPhotoCount < 1)
|| (!value.photoRequired && value.minPhotoCount !== 0)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['minPhotoCount'], message: 'photo rule mismatch' });
}
});
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
const settlementPayoutStateSchema = z.enum([
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM'
@@ -101,14 +121,19 @@ const reclaimSchema = z.object({
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
limit: z.coerce.number().int().min(1).max(100).default(20)
});
const photoCleanupSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20)
}).strict();
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats' | 'managerStatistics'>;
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
| 'listTemplates' | 'upsertTemplate'
| 'stats' | 'managerStatistics'>;
mediaStorage?: MediaStorage;
payoutService?: Pick<CleaningPayoutService,
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
@@ -130,6 +155,28 @@ export async function registerCleaningRoutes(
);
}
app.get('/admin-api/cleaning/templates', 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.listTemplates(actor),
traceId: request.traceId
}));
});
app.put('/admin-api/cleaning/templates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = cleaningTemplateSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.upsertTemplate({ ...actor, ...body.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
@@ -291,6 +338,18 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/tasks/:taskId/submissions', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
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.listSubmissions({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -366,14 +425,25 @@ export async function registerCleaningRoutes(
return invalid(reply, request.traceId);
}
return handle(reply, request.traceId, async () => {
await options.repository.assertCanUploadPhoto({ ...actor, taskId: params.data.taskId });
const context = await options.repository.assertCanUploadPhoto({
...actor, taskId: params.data.taskId
});
const image = await options.mediaStorage!.storeImage({
tenantId: actor.tenantId,
storeId: context.storeId,
originalName,
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
body: request.body as Buffer
});
return reply.status(201).send({ code: 0, data: image, traceId: request.traceId });
try {
const recorded = await options.repository.recordPhotoUpload({
...actor, taskId: params.data.taskId, image
});
return reply.status(201).send({ code: 0, data: recorded, traceId: request.traceId });
} catch (error) {
await options.mediaStorage!.deleteImage(image.storagePath);
throw error;
}
});
});
@@ -473,6 +543,41 @@ export async function registerCleaningRoutes(
}));
});
app.post('/admin-api/cleaning/photos/cleanup', async (request, reply) => {
if (!options.mediaStorage) return reply.status(501).send({
code: 'CLEANING_PHOTO_CLEANUP_UNAVAILABLE',
message: 'Cleaning photo storage is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = photoCleanupSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
const claimed = await options.repository.claimExpiredPhotoUploads({
...actor, limit: body.data.limit
});
const deletedIds: string[] = [];
const failedIds: string[] = [];
for (const photo of claimed) {
try {
await options.mediaStorage!.deleteImage(photo.storagePath);
deletedIds.push(photo.id);
} catch {
failedIds.push(photo.id);
}
}
const result = await options.repository.markPhotoUploadsDeleted({
...actor, photoIds: deletedIds
});
return {
code: 0,
data: { claimed: claimed.length, deleted: result.deleted, failedIds },
traceId: request.traceId
};
});
});
app.post('/admin-api/cleaning/settlements', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -679,8 +784,7 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
&& !(error instanceof CleaningPayoutError)
&& !(error instanceof WechatPayError)) throw error;
const code = error.code;
const statusCode = code === 'CLEANING_TASK_FORBIDDEN'
|| code === 'CLEANING_SETTLEMENT_FORBIDDEN' ? 403 : 409;
const statusCode = code.endsWith('_FORBIDDEN') ? 403 : 409;
return reply.status(statusCode).send({
code,
message: 'The cleaning task request cannot be completed.',
+91 -5
View File
@@ -77,6 +77,25 @@ const app = await buildApp({
},
async assertCanUploadPhoto(input) {
calls.push(['assertCanUploadPhoto', input]);
return { storeId: '11', maxPhotoCount: 3 };
},
async recordPhotoUpload(input) {
calls.push(['recordPhotoUpload', input]);
return { id: '701', ...input.image };
},
async listTemplates(input) {
calls.push(['listTemplates', input]);
return [{
id: '801', scopeKey: 'STORE:11', scopeType: 'STORE', storeId: '11', roomId: null,
name: '门店清洁', requirement: '桌面与地面', photoRequired: true,
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START',
version: 2, status: 'ACTIVE', appliesToExistingTasks: false
}];
},
async upsertTemplate(input) {
calls.push(['upsertTemplate', input]);
return { id: '801', scopeKey: `STORE:${input.scopeId}`, ...input,
storeId: input.scopeId, roomId: null, version: 3, appliesToExistingTasks: false };
},
async submit(input) {
calls.push(['submit', input]);
@@ -116,6 +135,15 @@ const app = await buildApp({
createdAt: new Date()
}];
},
async listSubmissions(input) {
calls.push(['listSubmissions', input]);
return [{
id: '9101', taskId: input.taskId, revision: 2, status: 'REJECTED',
photoUrls: ['https://api.txyundm.cn/uploads/old.webp'], note: 'first',
rejectReason: 'photo is unclear', submittedBy: '31', reviewedBy: '31',
submittedAt: new Date(), reviewedAt: new Date()
}];
},
async addMember(input) {
calls.push(['addMember', input]);
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
@@ -171,6 +199,14 @@ const app = await buildApp({
calls.push(['reclaimTimeouts', input]);
return { reclaimed: 2, taskIds: ['101', '102'] };
},
async claimExpiredPhotoUploads(input) {
calls.push(['claimExpiredPhotoUploads', input]);
return [{ id: '701', storagePath: 'tenants/7/stores/11/orphan.webp' }];
},
async markPhotoUploadsDeleted(input) {
calls.push(['markPhotoUploadsDeleted', input]);
return { deleted: input.photoIds.length };
},
async stats(input) {
calls.push(['stats', input]);
return {
@@ -281,19 +317,44 @@ const app = await buildApp({
async storeImage(input) {
calls.push(['storeImage', input]);
return {
storagePath: 'tenants/7/shared/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp',
storagePath: 'tenants/7/stores/11/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp',
mimeType: 'image/webp',
byteSize: input.body.length,
width: 640,
height: 480,
checksumSha256: 'abc'
};
},
async deleteImage(storagePath) {
calls.push(['deleteImage', storagePath]);
}
}
}
});
const templates = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/templates',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(templates.statusCode, 200);
assert.equal(templates.json().data[0].scopeKey, 'STORE:11');
const updatedTemplate = await app.inject({
method: 'PUT',
url: '/admin-api/cleaning/templates',
headers: { authorization: `Bearer ${token}` },
payload: {
scopeType: 'STORE', scopeId: '11', name: '门店清洁', requirement: '桌面与地面',
photoRequired: true, minPhotoCount: 2, maxPhotoCount: 3,
exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
}
});
assert.equal(updatedTemplate.statusCode, 200);
assert.equal(updatedTemplate.json().data.appliesToExistingTasks, false);
assert.equal(calls.at(-1)[0], 'upsertTemplate');
const hall = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
@@ -396,9 +457,11 @@ const photo = await app.inject({
payload: Buffer.from('fake-image')
});
assert.equal(photo.statusCode, 201);
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp');
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-1)[0], 'storeImage');
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp');
assert.equal(calls.at(-3)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-2)[0], 'storeImage');
assert.equal(calls.at(-2)[1].storeId, '11');
assert.equal(calls.at(-1)[0], 'recordPhotoUpload');
const submit = await app.inject({
method: 'POST',
@@ -498,6 +561,16 @@ assert.equal(calls.at(-1)[0], 'listEvents');
assert.equal(calls.at(-1)[1].taskId, '101');
assert.equal(events.json().data[0].action, 'SUBMIT');
const submissions = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/tasks/101/submissions',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(submissions.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listSubmissions');
assert.equal(submissions.json().data[0].revision, 2);
assert.equal(submissions.json().data[0].photoUrls[0], 'https://api.txyundm.cn/uploads/old.webp');
const addedMember = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/members',
@@ -664,6 +737,19 @@ assert.equal(reclaimed.statusCode, 200);
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
const cleanedPhotos = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/photos/cleanup',
headers: { authorization: `Bearer ${token}` },
payload: { limit: 10 }
});
assert.equal(cleanedPhotos.statusCode, 200);
assert.deepEqual(cleanedPhotos.json().data, { claimed: 1, deleted: 1, failedIds: [] });
assert.equal(calls.at(-3)[0], 'claimExpiredPhotoUploads');
assert.equal(calls.at(-2)[0], 'deleteImage');
assert.equal(calls.at(-1)[0], 'markPhotoUploadsDeleted');
assert.deepEqual(calls.at(-1)[1].photoIds, ['701']);
const stats = await app.inject({
method: 'GET',
url: '/app-api/cleaning/stats',
+18 -1
View File
@@ -21,7 +21,18 @@ try {
assert.equal(image.mimeType, 'image/webp');
assert.ok(image.width <= 1920);
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/);
assert.ok((await readFile(join(root, ...image.storagePath.split('/')))).length > 0);
const storedPath = join(root, ...image.storagePath.split('/'));
const storedBytes = await readFile(storedPath);
assert.ok(storedBytes.length > 0);
const storedMetadata = await sharp(storedBytes).metadata();
assert.equal(storedMetadata.exif, undefined);
assert.equal(storedMetadata.icc, undefined);
await assert.rejects(
() => storage.storeImage({
tenantId: '7', originalName: 'spoofed.jpg', contentType: 'image/jpeg', body: png
}),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_DECODE_FAILED'
);
await assert.rejects(
() => storage.storeImage({
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
@@ -29,6 +40,12 @@ try {
}),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID'
);
await storage.deleteImage(image.storagePath);
await assert.rejects(() => readFile(storedPath), (error) => error.code === 'ENOENT');
await assert.rejects(
() => storage.deleteImage('../outside.webp'),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_PATH_INVALID'
);
} finally {
await rm(root, { recursive: true, force: true });
}
+19 -1
View File
@@ -108,6 +108,9 @@ const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_l
const adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.sql');
const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_password_auth.verify.sql');
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -489,7 +492,22 @@ assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
assert.match(adminAuthUpSql, /'2026081004'/);
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/);
for (const table of [
'qipai_cleaning_templates', 'qipai_cleaning_task_photos', 'qipai_cleaning_task_submissions'
]) {
assert.match(cleaningRulesUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(cleaningRulesDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(cleaningRulesVerifySql, new RegExp(`'${table}'`));
}
for (const column of [
'cleaning_template_id', 'photo_required', 'min_photo_count', 'max_photo_count',
'exempt_policy', 'rework_count'
]) assert.match(cleaningRulesUpSql, new RegExp(`ADD COLUMN ${column}`));
assert.match(cleaningRulesUpSql, /uq_qipai_cleaning_submission_revision/);
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
assert.match(cleaningRulesVerifySql, /'2026081005'/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
console.log('PASS: M01-B through M08-D migration contracts are present.');
console.log('PASS: M01-B through M09-B migration contracts are present.');
+4 -2
View File
@@ -43,7 +43,8 @@ assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql$/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -51,7 +52,8 @@ const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
const calls = [];
const fakePool = {
+150 -11
View File
@@ -60,6 +60,9 @@ const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_cleaning_task_photos',
'qipai_cleaning_task_submissions',
'qipai_cleaning_templates',
'qipai_collection_accounts',
'qipai_device_alerts',
'qipai_device_channels',
@@ -139,13 +142,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004']
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
);
return rows;
}
@@ -1833,7 +1836,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081004');
assert.equal(overview.latestMigration.version, '2026081005');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -2528,6 +2531,19 @@ async function assertCleaningTaskTransactions(pool, context) {
[context.tenantId, taskId, userId, memberRole, rewardCents, removedAt, settledAt]
);
};
const recordPhoto = async (taskId, userId, suffix) => repository.recordPhotoUpload({
...cleanerActor(userId, `m09b-photo-${suffix}`),
taskId,
image: {
storagePath: `tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
mimeType: 'image/webp',
byteSize: 1024,
width: 1280,
height: 720,
checksumSha256: suffix.padEnd(64, '0').slice(0, 64)
}
});
const rollbackTaskId = await insertTask();
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
@@ -2650,9 +2666,10 @@ async function assertCleaningTaskTransactions(pool, context) {
await repository.start({
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
});
const firstPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-first');
await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId,
photoUrls: ['https://api.txyundm.cn/uploads/m09a-first.webp'], note: 'first submit'
photoUrls: [firstPhoto.publicUrl], note: 'first submit'
});
await repository.reject({
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
@@ -2666,9 +2683,10 @@ async function assertCleaningTaskTransactions(pool, context) {
await repository.rework({
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
});
const secondPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-second');
await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId,
photoUrls: ['https://api.txyundm.cn/uploads/m09a-second.webp'], note: 'second submit'
photoUrls: [secondPhoto.publicUrl], note: 'second submit'
});
const completeInput = {
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
@@ -2688,6 +2706,74 @@ async function assertCleaningTaskTransactions(pool, context) {
status: lifecycleRows[0].status,
completedSet: Number(lifecycleRows[0].completedSet)
}, { status: 'COMPLETED', completedSet: 1 });
const [submissionRows] = await pool.query(
`SELECT revision, status, JSON_UNQUOTE(JSON_EXTRACT(photo_urls_json, '$[0]')) AS photoUrl,
reject_reason AS rejectReason
FROM qipai_cleaning_task_submissions
WHERE tenant_id = ? AND task_id = ? ORDER BY revision`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(submissionRows.map((row) => ({
revision: Number(row.revision), status: row.status,
photoUrl: row.photoUrl, rejectReason: row.rejectReason
})), [
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' },
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' }
]);
const [photoRows] = await pool.query(
`SELECT public_url AS publicUrl, status, attached_revision AS attachedRevision
FROM qipai_cleaning_task_photos
WHERE tenant_id = ? AND task_id = ? ORDER BY attached_revision`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(photoRows.map((row) => ({
publicUrl: row.publicUrl, status: row.status, revision: Number(row.attachedRevision)
})), [
{ publicUrl: firstPhoto.publicUrl, status: 'ATTACHED', revision: 2 },
{ publicUrl: secondPhoto.publicUrl, status: 'ATTACHED', revision: 3 }
]);
const submissionHistory = await repository.listSubmissions({
...managerActor('m09b-submission-history'), taskId: lifecycleTaskId
});
assert.deepEqual(submissionHistory.map((submission) => ({
revision: submission.revision,
status: submission.status,
photoUrl: submission.photoUrls[0],
rejectReason: submission.rejectReason
})), [
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' },
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' }
]);
const cleanupTaskId = await insertTask({
status: 'STARTED', cleanerUserId: firstCleanerId,
claimedAt: new Date(), startedAt: new Date()
});
const expiredPhoto = await recordPhoto(cleanupTaskId, firstCleanerId, 'm09b-expired');
await pool.query(
`UPDATE qipai_cleaning_task_photos SET retention_until = TIMESTAMPADD(DAY, -1, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, expiredPhoto.id]
);
const expiredClaims = await repository.claimExpiredPhotoUploads({
...managerActor('m09b-photo-cleanup-claim'), limit: 10
});
assert.deepEqual(expiredClaims, [{ id: expiredPhoto.id, storagePath: expiredPhoto.storagePath }]);
assert.deepEqual(
await repository.markPhotoUploadsDeleted({
...managerActor('m09b-photo-cleanup-delete'), photoIds: [expiredPhoto.id]
}),
{ deleted: 1 }
);
const [deletedPhotoRows] = await pool.query(
`SELECT status, deleted_at IS NOT NULL AS deleted
FROM qipai_cleaning_task_photos WHERE tenant_id = ? AND id = ?`,
[context.tenantId, expiredPhoto.id]
);
assert.deepEqual(
{ status: deletedPhotoRows[0].status, deleted: Number(deletedPhotoRows[0].deleted) },
{ status: 'ORPHANED', deleted: 1 }
);
const [lifecycleEventRows] = await pool.query(
`SELECT action FROM qipai_cleaning_task_events
WHERE tenant_id = ? AND task_id = ? ORDER BY id`,
@@ -2782,6 +2868,26 @@ async function assertCleaningTaskTransactions(pool, context) {
assert.equal(settledMemberRows[0].removedAt, null);
assert.equal(Number(settledMemberRows[0].settled), 1);
const deniedExemptTaskId = await insertTask();
await pool.query(
`UPDATE qipai_cleaning_tasks SET exempt_policy = 'DISABLED'
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, deniedExemptTaskId]
);
await assert.rejects(
() => repository.exempt({
...managerActor('m09b-exempt-denied'), taskId: deniedExemptTaskId, note: 'not allowed'
}),
(error) => error instanceof CleaningTaskError && error.code === 'CLEANING_EXEMPT_POLICY_DENIED'
);
const template = await repository.upsertTemplate({
...managerActor('m09b-template-v1'), scopeType: 'TENANT', name: 'M09-B tenant default',
requirement: '两张照片并在开始前决定免清洁', photoRequired: true,
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
});
assert.equal(template.version, 1);
const [orderResult] = await pool.query(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
@@ -2812,7 +2918,10 @@ async function assertCleaningTaskTransactions(pool, context) {
orderConnection.release();
}
const [generatedRows] = await pool.query(
`SELECT t.status,
`SELECT t.status, t.cleaning_template_id AS cleaningTemplateId,
t.cleaning_template_version AS cleaningTemplateVersion,
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id
AND e.action = 'AUTO_CREATE') AS eventCount
@@ -2822,10 +2931,34 @@ async function assertCleaningTaskTransactions(pool, context) {
);
assert.equal(generatedRows.length, 1);
assert.equal(generatedRows[0].status, 'WAITING');
assert.equal(String(generatedRows[0].cleaningTemplateId), template.id);
assert.equal(Number(generatedRows[0].cleaningTemplateVersion), 1);
assert.equal(Number(generatedRows[0].photoRequired), 1);
assert.equal(Number(generatedRows[0].minPhotoCount), 2);
assert.equal(Number(generatedRows[0].maxPhotoCount), 3);
assert.equal(generatedRows[0].exemptPolicy, 'BEFORE_START');
assert.equal(Number(generatedRows[0].eventCount), 1);
const updatedTemplate = await repository.upsertTemplate({
...managerActor('m09b-template-v2'), scopeType: 'TENANT', name: 'M09-B tenant default',
requirement: '新任务无需照片', photoRequired: false,
minPhotoCount: 0, maxPhotoCount: 2, exemptPolicy: 'DISABLED', status: 'ACTIVE'
});
assert.equal(updatedTemplate.version, 2);
const [snapshotRows] = await pool.query(
`SELECT cleaning_template_version AS version, min_photo_count AS minPhotoCount,
exempt_policy AS exemptPolicy
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, finishedOrderId]
);
assert.deepEqual({
version: Number(snapshotRows[0].version),
minPhotoCount: Number(snapshotRows[0].minPhotoCount),
exemptPolicy: snapshotRows[0].exemptPolicy
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
console.log(
'PASS: M09-A cleaning claims, lifecycle events, rollback, reassignment, timeout reclaim and order generation are transactional.'
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
);
}
@@ -2875,7 +3008,8 @@ try {
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2904,7 +3038,7 @@ try {
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M06-C migration tables.');
console.log('PASS: down removed all M01-B through M09-B migration tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -2932,7 +3066,8 @@ try {
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -3069,7 +3204,11 @@ try {
'idempotent cleaning completion trace',
'SKIP LOCKED cleaning timeout reclaim batch',
'settled cleaning member preservation',
'finished order creates one cleaning task'
'finished order creates one cleaning task',
'cleaning template snapshot remains stable after config version change',
'rejected and accepted cleaning photo revisions remain distinguishable',
'expired unattached cleaning photo retention cleanup',
'task-level cleaning exemption policy denial'
]
}, null, 2));
} finally {
@@ -6,6 +6,7 @@ import {
import { DeviceControlError } from '../dist/devices/device-control-service.js';
const calls = [];
let activeRoomOrder = false;
let currentOrder = {
id: 31,
tenantId: 7,
@@ -17,6 +18,11 @@ let currentOrder = {
};
const service = new OrderDeviceAutomationService({
async execute(sql, params) {
if (sql.includes('id <> ?')) {
assert.equal(params[0], '7');
assert.equal(params[2], '31');
return [activeRoomOrder ? [{ id: 32 }] : [], []];
}
if (sql.includes('FROM qipai_orders')) {
assert.equal(params[0], '7');
assert.equal(params[1], '31');
@@ -91,6 +97,17 @@ await service.handleTask(task({
assert.equal(calls.at(-2)[0], 'cancelTask');
assert.equal(calls.at(-1)[2].on, false);
currentOrder = { ...currentOrder, status: 'FINISHED' };
activeRoomOrder = true;
const callCountBeforeProtectedFinish = calls.length;
const protectedFinish = await service.handleTask(task({
tenantId: '7', orderId: '31', event: 'ORDER_FINISHED', traceId: 'm09b-finish'
}));
assert.equal(protectedFinish.skipped, true);
assert.equal(protectedFinish.reason, 'ROOM_HAS_ACTIVE_ORDER');
assert.equal(calls.length, callCountBeforeProtectedFinish);
activeRoomOrder = false;
currentOrder = { ...currentOrder, roomId: 52, status: 'IN_PROGRESS' };
const changed = await service.handleTask(task({
tenantId: '7',