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.',