diff --git a/admin/src/api.ts b/admin/src/api.ts index 97441f5..bfe2bf6 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -9,8 +9,12 @@ import type { CleaningSettlementDetail, CleaningStatistics, CleaningTask, + CleaningTemplate, + CleaningTemplateScope, + CleaningExemptPolicy, CleaningTaskEvent, CleaningTaskMember, + CleaningTaskSubmission, DecorationComponent, DecorationVersion, FranchiseApplication, @@ -449,6 +453,29 @@ export function getCleaningStatistics( return request(session, `/cleaning/statistics${query ? `?${query}` : ''}`); } +export function listCleaningTemplates(session: ApiSession) { + return request(session, '/cleaning/templates'); +} + +export function upsertCleaningTemplate( + session: ApiSession, + input: { + scopeType: CleaningTemplateScope; + scopeId?: string; + name: string; + requirement: string; + photoRequired: boolean; + minPhotoCount: number; + maxPhotoCount: number; + exemptPolicy: CleaningExemptPolicy; + status: 'ACTIVE' | 'DISABLED'; + } +) { + return request(session, '/cleaning/templates', { + method: 'PUT', body: JSON.stringify(input) + }); +} + export function listStaffUsers( session: ApiSession, input: { @@ -729,6 +756,13 @@ export function listCleaningTaskEvents(session: ApiSession, taskId: string) { ); } +export function listCleaningTaskSubmissions(session: ApiSession, taskId: string) { + return request( + session, + `/cleaning/tasks/${encodeURIComponent(taskId)}/submissions` + ); +} + export function addCleaningTaskMember( session: ApiSession, taskId: string, diff --git a/admin/src/components/CleaningRulesPanel.vue b/admin/src/components/CleaningRulesPanel.vue new file mode 100644 index 0000000..a4cad91 --- /dev/null +++ b/admin/src/components/CleaningRulesPanel.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/admin/src/components/CleaningTasksPanel.vue b/admin/src/components/CleaningTasksPanel.vue index d2a1799..fc1591a 100644 --- a/admin/src/components/CleaningTasksPanel.vue +++ b/admin/src/components/CleaningTasksPanel.vue @@ -149,7 +149,7 @@ size="small" type="warning" :icon="Ban" - :disabled="!canExempt(row.status)" + :disabled="!canExempt(row)" @click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })" > {{ '\u514d\u6e05\u6d01' }} @@ -323,7 +323,7 @@ - +
任务号{{ detailDialog.task.taskNo }} @@ -341,6 +341,12 @@ {{ compactText(detailDialog.task.requirement) }} + + {{ detailDialog.task.photoRequired ? `${detailDialog.task.minPhotoCount}–${detailDialog.task.maxPhotoCount} 张` : `可选,最多 ${detailDialog.task.maxPhotoCount} 张` }} + + + 模板 v{{ detailDialog.task.cleaningTemplateVersion }} · 照片修订 {{ detailDialog.task.photoRevision }} · 补做 {{ detailDialog.task.reworkCount }} 次 + {{ compactText(detailDialog.task.rejectReason) }} @@ -351,6 +357,46 @@ 提交{{ shortDate(detailDialog.task.submittedAt) }} 完成{{ shortDate(detailDialog.task.completedAt) }}
+
+
+

验收照片版本

+ {{ detailDialog.submissions.length }} 次提交 +
+
+
+
+ 第 {{ submission.revision }} 版 + + {{ submission.status }} + + {{ shortDate(submission.submittedAt) }} +
+

+ 驳回原因:{{ submission.rejectReason }} +

+

提交备注:{{ submission.note }}

+
+ +
+ +
+
+ +

操作流水

@@ -451,6 +497,7 @@ import { addCleaningTaskMember, listCleaningTaskEvents, listCleaningTaskMembers, + listCleaningTaskSubmissions, removeCleaningTaskMember } from '../api'; import { compactText, money, shortDate } from '../format'; @@ -458,6 +505,7 @@ import type { CleaningTask, CleaningTaskEvent, CleaningTaskMember, + CleaningTaskSubmission, ManagedUser, PageResult, TaskStatus @@ -510,16 +558,20 @@ const detailDialog = reactive<{ open: boolean; loadingEvents: boolean; loadingMembers: boolean; + loadingSubmissions: boolean; task: CleaningTask | null; events: CleaningTaskEvent[]; members: CleaningTaskMember[]; + submissions: CleaningTaskSubmission[]; }>({ open: false, loadingEvents: false, loadingMembers: false, + loadingSubmissions: false, task: null, events: [], - members: [] + members: [], + submissions: [] }); const memberDialog = reactive({ open: false, @@ -536,7 +588,7 @@ const selectedSubmittedTasks = computed(() => selectedTasks.value .filter((task) => task.status === 'SUBMITTED')); const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length); const selectedExemptableTasks = computed(() => selectedTasks.value - .filter((task) => canExempt(task.status))); + .filter((task) => canExempt(task))); const selectedExemptableCount = computed(() => selectedExemptableTasks.value.length); watch( @@ -553,11 +605,13 @@ function taskTag(status: TaskStatus) { } function canSelectTask(row: CleaningTask) { - return row.status === 'SUBMITTED' || canExempt(row.status); + return row.status === 'SUBMITTED' || canExempt(row); } -function canExempt(status: TaskStatus) { - return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(status); +function canExempt(task: CleaningTask) { + if (task.exemptPolicy === 'DISABLED') return false; + if (task.exemptPolicy === 'BEFORE_START') return ['WAITING', 'CLAIMED'].includes(task.status); + return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(task.status); } function handleSelectionChange(rows: CleaningTask[]) { @@ -583,20 +637,25 @@ async function openDetail(row: CleaningTask) { detailDialog.task = row; detailDialog.events = []; detailDialog.members = []; + detailDialog.submissions = []; detailDialog.loadingEvents = true; detailDialog.loadingMembers = true; + detailDialog.loadingSubmissions = true; try { - const [events, members] = await Promise.all([ + const [events, members, submissions] = await Promise.all([ listCleaningTaskEvents(props.session, row.id), - listCleaningTaskMembers(props.session, row.id) + listCleaningTaskMembers(props.session, row.id), + listCleaningTaskSubmissions(props.session, row.id) ]); detailDialog.events = events; detailDialog.members = members; + detailDialog.submissions = submissions; } catch (error) { ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败'); } finally { detailDialog.loadingEvents = false; detailDialog.loadingMembers = false; + detailDialog.loadingSubmissions = false; } } @@ -779,10 +838,17 @@ function exportTaskDetail() { `${event.action}:${shortDate(event.createdAt)}`, `${compactText(event.fromStatus)} -> ${event.toStatus} ${compactText(event.actorId)} ${compactText(event.note)}` ]), - ...task.photoUrls.map((url, index) => [ - 'photo', - `photo_${index + 1}`, - url + ...detailDialog.submissions.flatMap((submission) => [ + [ + 'submission', + `revision_${submission.revision}`, + `${submission.status} ${submission.rejectReason || submission.note || ''}` + ], + ...submission.photoUrls.map((url, index) => [ + 'photo', + `revision_${submission.revision}_photo_${index + 1}`, + url + ]) ]) ]); } diff --git a/admin/src/components/CleaningWorkspace.vue b/admin/src/components/CleaningWorkspace.vue index 0b94ff0..4e376e3 100644 --- a/admin/src/components/CleaningWorkspace.vue +++ b/admin/src/components/CleaningWorkspace.vue @@ -144,6 +144,9 @@ @reset-sessions="handleResetCleanerSessions" /> + + + @@ -159,6 +162,7 @@ import CleaningTasksPanel from './CleaningTasksPanel.vue'; import CleaningSettlementsPanel from './CleaningSettlementsPanel.vue'; import CleaningStatisticsPanel from './CleaningStatisticsPanel.vue'; import CleanersPanel from './CleanersPanel.vue'; +import CleaningRulesPanel from './CleaningRulesPanel.vue'; import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue'; import { ApiError, diff --git a/admin/src/styles.css b/admin/src/styles.css index 4a6aa6c..7ecf022 100644 --- a/admin/src/styles.css +++ b/admin/src/styles.css @@ -1291,6 +1291,43 @@ textarea { background: #f5f8fb; } +.submission-list { + display: grid; + gap: 12px; + padding: 12px; +} + +.submission-card { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid #e1e8f0; + border-radius: 8px; + background: #fbfcfe; +} + +.submission-card > header { + justify-content: flex-start; + padding: 0; + border: 0; + background: transparent; +} + +.submission-card > header span:last-child { + margin-left: auto; +} + +.submission-reason, +.submission-note { + margin: 0; + color: #60708a; + font-size: 13px; +} + +.submission-reason { + color: #b42318; +} + .trend-list { display: grid; gap: 10px; diff --git a/admin/src/types.ts b/admin/src/types.ts index 108017c..4124981 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -614,8 +614,16 @@ export interface CleaningTask { priority: number; rewardCents: number; requirement: string; + cleaningTemplateId: string | null; + cleaningTemplateVersion: number; + photoRequired: boolean; + minPhotoCount: number; + maxPhotoCount: number; + exemptPolicy: CleaningExemptPolicy; photoUrls: string[]; rejectReason: string; + reworkCount: number; + photoRevision: number; claimedAt?: string; startedAt?: string; submittedAt?: string; @@ -625,6 +633,27 @@ export interface CleaningTask { updatedAt?: string; } +export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM'; +export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE'; + +export interface CleaningTemplate { + id: string; + scopeKey: string; + scopeType: CleaningTemplateScope; + storeId: string | null; + roomId: string | null; + name: string; + requirement: string; + photoRequired: boolean; + minPhotoCount: number; + maxPhotoCount: number; + exemptPolicy: CleaningExemptPolicy; + version: number; + status: 'ACTIVE' | 'DISABLED'; + updatedAt: string; + appliesToExistingTasks: false; +} + export interface CleaningTaskMember { id: string; taskId: string; @@ -649,6 +678,20 @@ export interface CleaningTaskEvent { createdAt: string; } +export interface CleaningTaskSubmission { + id: string; + taskId: string; + revision: number; + status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED'; + photoUrls: string[]; + note: string; + rejectReason: string; + submittedBy: string; + reviewedBy: string | null; + submittedAt: string; + reviewedAt: string | null; +} + export interface CleaningSettlement { id: string; settlementNo: string; diff --git a/backend/src/cleaning/cleaning-task-repository.ts b/backend/src/cleaning/cleaning-task-repository.ts index 876c10a..ea55624 100644 --- a/backend/src/cleaning/cleaning-task-repository.ts +++ b/backend/src/cleaning/cleaning-task-repository.ts @@ -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( + `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( + `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( + `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( - `SELECT status, cleaner_user_id AS cleanerUserId + const [currentRows] = await connection.execute( + `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( `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( + `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( - `SELECT status, cleaner_user_id AS cleanerUserId + const [rows] = await connection.execute( + `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( + `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( - `SELECT status, cleaner_user_id AS cleanerUserId + const [rows] = await this.pool.execute( + `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( + `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( + `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( + `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( + `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( + `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( + `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( `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( @@ -1280,6 +1615,61 @@ export class CleaningTaskRepository { ); } + private async getTemplate( + connection: Pick | PoolConnection, + tenantId: string, + templateId: string + ) { + const [rows] = await connection.execute( + `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( + `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( + `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( @@ -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), diff --git a/backend/src/content/media-storage.ts b/backend/src/content/media-storage.ts index 2202014..ce62315 100644 --- a/backend/src/content/media-storage.ts +++ b/backend/src/content/media-storage.ts @@ -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 { + 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; + } + } } diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 5cb0a35..349de9e 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -53,7 +53,8 @@ const migrationFiles: Record = { '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 = { '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', diff --git a/backend/src/devices/order-device-automation-service.ts b/backend/src/devices/order-device-automation-service.ts index 0d65bdf..7dd58e3 100644 --- a/backend/src/devices/order-device-automation-service.ts +++ b/backend/src/devices/order-device-automation-service.ts @@ -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( + `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( `SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId, diff --git a/backend/src/routes/cleaning.ts b/backend/src/routes/cleaning.ts index 8295d35..0b2ca85 100644 --- a/backend/src/routes/cleaning.ts +++ b/backend/src/routes/cleaning.ts @@ -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; + | 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted' + | 'listTemplates' | 'upsertTemplate' + | 'stats' | 'managerStatistics'>; mediaStorage?: MediaStorage; payoutService?: Pick { + 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.', diff --git a/backend/tests/cleaning-route.test.mjs b/backend/tests/cleaning-route.test.mjs index 522698a..183fcca 100644 --- a/backend/tests/cleaning-route.test.mjs +++ b/backend/tests/cleaning-route.test.mjs @@ -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', diff --git a/backend/tests/content-management.test.mjs b/backend/tests/content-management.test.mjs index 0151915..91cb2f3 100644 --- a/backend/tests/content-management.test.mjs +++ b/backend/tests/content-management.test.mjs @@ -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 }); } diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 5923a9c..196b775 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index f4fc313..3345289 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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 = { diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 3286189..33c6d0d 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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 { diff --git a/backend/tests/order-device-automation.test.mjs b/backend/tests/order-device-automation.test.mjs index c3a9f1b..8475195 100644 --- a/backend/tests/order-device-automation.test.mjs +++ b/backend/tests/order-device-automation.test.mjs @@ -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', diff --git a/database/migrations/2026081005_m09b_cleaning_rules.down.sql b/database/migrations/2026081005_m09b_cleaning_rules.down.sql new file mode 100644 index 0000000..4674d77 --- /dev/null +++ b/database/migrations/2026081005_m09b_cleaning_rules.down.sql @@ -0,0 +1,18 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026081005'; + +DROP TABLE IF EXISTS qipai_cleaning_task_submissions; +DROP TABLE IF EXISTS qipai_cleaning_task_photos; + +ALTER TABLE qipai_cleaning_tasks + DROP FOREIGN KEY fk_qipai_cleaning_tasks_template, + DROP CONSTRAINT chk_qipai_cleaning_task_photo_rule, + DROP CONSTRAINT chk_qipai_cleaning_task_exempt_policy, + DROP COLUMN rework_count, + DROP COLUMN exempt_policy, + DROP COLUMN max_photo_count, + DROP COLUMN min_photo_count, + DROP COLUMN photo_required, + DROP COLUMN cleaning_template_version, + DROP COLUMN cleaning_template_id; + +DROP TABLE IF EXISTS qipai_cleaning_templates; diff --git a/database/migrations/2026081005_m09b_cleaning_rules.up.sql b/database/migrations/2026081005_m09b_cleaning_rules.up.sql new file mode 100644 index 0000000..12203a1 --- /dev/null +++ b/database/migrations/2026081005_m09b_cleaning_rules.up.sql @@ -0,0 +1,108 @@ +CREATE TABLE IF NOT EXISTS qipai_cleaning_templates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + scope_key VARCHAR(64) NOT NULL, + scope_type VARCHAR(16) NOT NULL, + store_id BIGINT UNSIGNED NULL, + room_id BIGINT UNSIGNED NULL, + name VARCHAR(128) NOT NULL, + requirement VARCHAR(512) NOT NULL DEFAULT '', + photo_required TINYINT(1) NOT NULL DEFAULT 1, + min_photo_count TINYINT UNSIGNED NOT NULL DEFAULT 1, + max_photo_count TINYINT UNSIGNED NOT NULL DEFAULT 9, + exempt_policy VARCHAR(32) NOT NULL DEFAULT 'ANY_ACTIVE', + version INT UNSIGNED NOT NULL DEFAULT 1, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + created_by BIGINT UNSIGNED NOT NULL, + updated_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_cleaning_templates_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_cleaning_templates_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_cleaning_templates_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id), + CONSTRAINT fk_qipai_cleaning_templates_creator FOREIGN KEY (created_by) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_cleaning_templates_updater FOREIGN KEY (updated_by) REFERENCES qipai_users(id), + CONSTRAINT chk_qipai_cleaning_template_scope CHECK (scope_type IN ('TENANT', 'STORE', 'ROOM')), + CONSTRAINT chk_qipai_cleaning_template_photos CHECK ( + min_photo_count <= max_photo_count AND max_photo_count <= 9 + AND ((photo_required = 1 AND min_photo_count >= 1) OR (photo_required = 0 AND min_photo_count = 0)) + ), + CONSTRAINT chk_qipai_cleaning_template_exempt CHECK ( + exempt_policy IN ('DISABLED', 'BEFORE_START', 'ANY_ACTIVE') + ), + CONSTRAINT chk_qipai_cleaning_template_status CHECK (status IN ('ACTIVE', 'DISABLED')), + UNIQUE KEY uq_qipai_cleaning_template_scope (tenant_id, scope_key), + KEY idx_qipai_cleaning_template_resolve (tenant_id, room_id, store_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE qipai_cleaning_tasks + ADD COLUMN cleaning_template_id BIGINT UNSIGNED NULL AFTER requirement, + ADD COLUMN cleaning_template_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER cleaning_template_id, + ADD COLUMN photo_required TINYINT(1) NOT NULL DEFAULT 1 AFTER cleaning_template_version, + ADD COLUMN min_photo_count TINYINT UNSIGNED NOT NULL DEFAULT 1 AFTER photo_required, + ADD COLUMN max_photo_count TINYINT UNSIGNED NOT NULL DEFAULT 9 AFTER min_photo_count, + ADD COLUMN exempt_policy VARCHAR(32) NOT NULL DEFAULT 'ANY_ACTIVE' AFTER max_photo_count, + ADD COLUMN rework_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER reject_reason, + ADD CONSTRAINT fk_qipai_cleaning_tasks_template + FOREIGN KEY (cleaning_template_id) REFERENCES qipai_cleaning_templates(id), + ADD CONSTRAINT chk_qipai_cleaning_task_photo_rule CHECK ( + min_photo_count <= max_photo_count AND max_photo_count <= 9 + AND ((photo_required = 1 AND min_photo_count >= 1) OR (photo_required = 0 AND min_photo_count = 0)) + ), + ADD CONSTRAINT chk_qipai_cleaning_task_exempt_policy CHECK ( + exempt_policy IN ('DISABLED', 'BEFORE_START', 'ANY_ACTIVE') + ); + +CREATE TABLE IF NOT EXISTS qipai_cleaning_task_photos ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NOT NULL, + uploaded_by BIGINT UNSIGNED NOT NULL, + storage_path VARCHAR(512) NOT NULL, + public_url VARCHAR(700) NOT NULL, + mime_type VARCHAR(64) NOT NULL, + byte_size INT UNSIGNED NOT NULL, + width INT UNSIGNED NOT NULL, + height INT UNSIGNED NOT NULL, + checksum_sha256 CHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PENDING', + attached_revision INT UNSIGNED NULL, + retention_until DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_cleaning_photos_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_cleaning_photos_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id), + CONSTRAINT fk_qipai_cleaning_photos_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_cleaning_photos_uploader FOREIGN KEY (uploaded_by) REFERENCES qipai_users(id), + CONSTRAINT chk_qipai_cleaning_photo_status CHECK (status IN ('PENDING', 'ATTACHED', 'ORPHANED')), + UNIQUE KEY uq_qipai_cleaning_photo_storage (tenant_id, storage_path), + UNIQUE KEY uq_qipai_cleaning_photo_url (tenant_id, public_url), + KEY idx_qipai_cleaning_photo_task (tenant_id, task_id, status, created_at), + KEY idx_qipai_cleaning_photo_retention (status, retention_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_cleaning_task_submissions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + revision INT UNSIGNED NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED', + photo_urls_json JSON NOT NULL, + note VARCHAR(512) NOT NULL DEFAULT '', + reject_reason VARCHAR(512) NOT NULL DEFAULT '', + submitted_by BIGINT UNSIGNED NOT NULL, + reviewed_by BIGINT UNSIGNED NULL, + submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + reviewed_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_cleaning_submissions_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_cleaning_submissions_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id), + CONSTRAINT fk_qipai_cleaning_submissions_submitter FOREIGN KEY (submitted_by) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_cleaning_submissions_reviewer FOREIGN KEY (reviewed_by) REFERENCES qipai_users(id), + CONSTRAINT chk_qipai_cleaning_submission_status CHECK (status IN ('SUBMITTED', 'ACCEPTED', 'REJECTED')), + UNIQUE KEY uq_qipai_cleaning_submission_revision (tenant_id, task_id, revision), + KEY idx_qipai_cleaning_submission_review (tenant_id, task_id, status, submitted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026081005', 'm09b_cleaning_rules'); diff --git a/database/migrations/2026081005_m09b_cleaning_rules.verify.sql b/database/migrations/2026081005_m09b_cleaning_rules.verify.sql new file mode 100644 index 0000000..054a93d --- /dev/null +++ b/database/migrations/2026081005_m09b_cleaning_rules.verify.sql @@ -0,0 +1,32 @@ +SELECT table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ( + 'qipai_cleaning_templates', + 'qipai_cleaning_task_photos', + 'qipai_cleaning_task_submissions' + ) +ORDER BY table_name; + +SELECT table_name, column_name +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'qipai_cleaning_tasks' + AND column_name IN ( + 'cleaning_template_id', 'cleaning_template_version', 'photo_required', + 'min_photo_count', 'max_photo_count', 'exempt_policy', 'rework_count' + ) +ORDER BY column_name; + +SELECT table_name, index_name +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND ((table_name = 'qipai_cleaning_templates' AND index_name = 'uq_qipai_cleaning_template_scope') + OR (table_name = 'qipai_cleaning_task_photos' AND index_name = 'uq_qipai_cleaning_photo_url') + OR (table_name = 'qipai_cleaning_task_submissions' AND index_name = 'uq_qipai_cleaning_submission_revision')) +GROUP BY table_name, index_name +ORDER BY table_name, index_name; + +SELECT version, name +FROM qipai_schema_migrations +WHERE version = '2026081005'; diff --git a/miniapp/pages/cleaner/tasks.js b/miniapp/pages/cleaner/tasks.js index 0609ffa..a1613ee 100644 --- a/miniapp/pages/cleaner/tasks.js +++ b/miniapp/pages/cleaner/tasks.js @@ -62,9 +62,14 @@ Page({ const taskId = event.currentTarget.dataset.taskId if (!taskId) return try { - const result = await wxChooseMedia(9) const current = this.data.selectedPhotosByTask[taskId] || [] - const next = current.concat(result.tempFiles.map((item) => item.tempFilePath)).slice(0, 9) + const maxPhotos = Math.max(1, Math.min(9, Number(event.currentTarget.dataset.maxPhotos || 9))) + if (current.length >= maxPhotos) { + this.setData({ errorMessage: `当前任务最多上传 ${maxPhotos} 张照片` }) + return + } + const result = await wxChooseMedia(maxPhotos - current.length) + const next = current.concat(result.tempFiles.map((item) => item.tempFilePath)).slice(0, maxPhotos) this.setData({ [`selectedPhotosByTask.${taskId}`]: next }) } catch (error) { this.setData({ errorMessage: error.message || '选择照片失败' }) @@ -74,8 +79,10 @@ Page({ async submitTask(event) { const taskId = event.currentTarget.dataset.taskId const localPhotos = this.data.selectedPhotosByTask[taskId] || [] - if (localPhotos.length === 0) { - this.setData({ errorMessage: '请先上传至少一张保洁照片' }) + const task = this.data.myTasks.find((item) => item.id === taskId) + const minPhotos = Number(task?.minPhotoCount ?? 1) + if (localPhotos.length < minPhotos) { + this.setData({ errorMessage: `请先选择至少 ${minPhotos} 张保洁照片` }) return } this.setData({ loading: true, errorMessage: '' }) @@ -125,7 +132,10 @@ function formatTask(task) { canStart: task.status === 'CLAIMED', canSubmit: task.status === 'STARTED', canRework: task.status === 'REJECTED', - canUpload: task.status === 'STARTED' || task.status === 'REJECTED', + canUpload: task.status === 'STARTED', + photoRuleText: task.photoRequired + ? `至少 ${task.minPhotoCount} 张,最多 ${task.maxPhotoCount} 张` + : `照片可选,最多 ${task.maxPhotoCount} 张`, } } diff --git a/miniapp/pages/cleaner/tasks.wxml b/miniapp/pages/cleaner/tasks.wxml index 42a8dd9..65c6a3a 100644 --- a/miniapp/pages/cleaner/tasks.wxml +++ b/miniapp/pages/cleaner/tasks.wxml @@ -69,8 +69,8 @@ - - 至少 1 张,最多 9 张 + + {{item.photoRuleText}} diff --git a/scripts/check-m09-b-cleaning-rules.mjs b/scripts/check-m09-b-cleaning-rules.mjs new file mode 100644 index 0000000..6cc7e48 --- /dev/null +++ b/scripts/check-m09-b-cleaning-rules.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const read = (path) => readFileSync(join(root, path), 'utf8'); + +for (const suffix of ['up', 'verify', 'down']) { + assert.ok( + existsSync(join(root, `database/migrations/2026081005_m09b_cleaning_rules.${suffix}.sql`)), + `M09-B ${suffix} migration is missing` + ); +} + +const migration = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql'); +for (const pattern of [ + 'qipai_cleaning_templates', + 'cleaning_template_version', + 'photo_required', + 'min_photo_count', + 'max_photo_count', + 'exempt_policy', + 'rework_count', + 'qipai_cleaning_task_photos', + 'retention_until', + 'attached_revision', + 'qipai_cleaning_task_submissions', + 'uq_qipai_cleaning_submission_revision' +]) assert.ok(migration.includes(pattern), `migration is missing ${pattern}`); + +const repository = read('backend/src/cleaning/cleaning-task-repository.ts'); +for (const pattern of [ + 'listTemplates', + 'upsertTemplate', + "'appliesToExistingTasks', FALSE", + 'listSubmissions', + 'CLEANING_PHOTO_OWNERSHIP_INVALID', + "status = 'ATTACHED'", + 'attached_revision = ?', + 'claimExpiredPhotoUploads', + 'markPhotoUploadsDeleted', + 'CLEANING_EXEMPT_POLICY_DENIED', + "ORDER BY FIELD(scope_type, 'ROOM', 'STORE', 'TENANT')" +]) assert.ok(repository.includes(pattern), `repository is missing ${pattern}`); + +const routes = read('backend/src/routes/cleaning.ts'); +for (const pattern of [ + '/admin-api/cleaning/templates', + '/admin-api/cleaning/tasks/:taskId/submissions', + '/admin-api/cleaning/photos/cleanup', + 'tenantId: actor.tenantId', + 'storeId: context.storeId', + 'deleteImage(image.storagePath)' +]) assert.ok(routes.includes(pattern), `cleaning routes are missing ${pattern}`); + +const media = read('backend/src/content/media-storage.ts'); +for (const pattern of [ + 'declaredFormat', + 'declaredExtension', + 'metadata.format', + '.webp({ quality: 82 })', + 'deleteImage' +]) assert.ok(media.includes(pattern), `media storage is missing ${pattern}`); + +const automation = read('backend/src/devices/order-device-automation-service.ts'); +for (const pattern of [ + 'ROOM_HAS_ACTIVE_ORDER', + "status IN ('PAID', 'RESERVED', 'IN_PROGRESS')", + 'room_id = ? AND id <> ?' +]) assert.ok(automation.includes(pattern), `device automation is missing ${pattern}`); + +const miniapp = read('miniapp/pages/cleaner/tasks.js') + + read('miniapp/pages/cleaner/tasks.wxml'); +for (const pattern of [ + 'minPhotoCount', 'maxPhotoCount', "task.status === 'STARTED'", 'photoRequired' +]) assert.ok(miniapp.includes(pattern), `cleaner miniapp is missing ${pattern}`); + +const admin = read('admin/src/components/CleaningRulesPanel.vue') + + read('admin/src/components/CleaningTasksPanel.vue') + + read('admin/src/api.ts'); +for (const pattern of [ + '配置变更不会静默改写在途任务', + 'listCleaningTemplates', + 'upsertCleaningTemplate', + 'listCleaningTaskSubmissions', + '验收照片版本', + 'submission.revision' +]) assert.ok(admin.includes(pattern), `admin cleaning workflow is missing ${pattern}`); + +const liveTest = read('backend/tests/mysql-migration-roundtrip.test.mjs'); +for (const pattern of [ + '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' +]) assert.ok(liveTest.includes(pattern), `live MySQL test is missing ${pattern}`); + +console.log('PASS: M09-B cleaning templates, photo security, versioned review and safe device shutdown gates are present.'); diff --git a/scripts/dev/windows/test-all.ps1 b/scripts/dev/windows/test-all.ps1 index 32c619e..e0a3585 100644 --- a/scripts/dev/windows/test-all.ps1 +++ b/scripts/dev/windows/test-all.ps1 @@ -32,6 +32,8 @@ node scripts/check-admin-m08-d-r1.mjs --source-only Assert-NativeSuccess "check-admin-m08-d-r1 source" node scripts/check-miniapp-m08-c.mjs Assert-NativeSuccess "check-miniapp-m08-c" +node scripts/check-m09-b-cleaning-rules.mjs +Assert-NativeSuccess "check-m09-b-cleaning-rules" if (Test-Path "admin/package.json") { Push-Location admin