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

This commit is contained in:
Codex
2026-08-10 21:49:07 +08:00
parent b507e87999
commit bc7cb8fab9
24 changed files with 1650 additions and 81 deletions
+34
View File
@@ -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<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`);
}
export function listCleaningTemplates(session: ApiSession) {
return request<CleaningTemplate[]>(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<CleaningTemplate>(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<CleaningTaskSubmission[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/submissions`
);
}
export function addCleaningTaskMember(
session: ApiSession,
taskId: string,
+246
View File
@@ -0,0 +1,246 @@
<template>
<section class="rules-panel">
<header class="panel-heading">
<div>
<p class="eyebrow">M09-B · 清洁规则</p>
<h3>门店与房间清洁模板</h3>
<p>模板按房间门店租户依次匹配保存后只影响新任务既有任务继续使用创建时快照</p>
</div>
<el-button :loading="loading" @click="loadTemplates">刷新</el-button>
</header>
<el-alert
title="配置变更不会静默改写在途任务;每次保存都会记录审计。"
type="info"
show-icon
:closable="false"
/>
<el-form class="rule-form" label-position="top" @submit.prevent>
<el-form-item label="作用范围">
<el-select v-model="draft.scopeType" @change="handleScopeChange">
<el-option label="租户默认" value="TENANT" />
<el-option label="指定门店" value="STORE" />
<el-option label="指定房间" value="ROOM" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'STORE'" label="门店">
<el-select v-model="draft.scopeId" filterable placeholder="请选择门店">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'ROOM'" label="所属门店">
<el-select v-model="draft.roomStoreId" filterable placeholder="先选择门店" @change="handleRoomStoreChange">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'ROOM'" label="房间">
<el-select v-model="draft.scopeId" filterable placeholder="请选择房间" :disabled="!draft.roomStoreId">
<el-option
v-for="room in rooms"
:key="room.id"
:label="`${room.roomNo} · ${room.name}`"
:value="room.id"
/>
</el-select>
</el-form-item>
<el-form-item label="模板名称">
<el-input v-model.trim="draft.name" maxlength="128" show-word-limit />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="draft.status">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item class="wide-field" label="清洁要求">
<el-input v-model.trim="draft.requirement" type="textarea" :rows="3" maxlength="512" show-word-limit />
</el-form-item>
<el-form-item label="必须上传照片">
<el-switch v-model="draft.photoRequired" @change="normalizePhotoRule" />
</el-form-item>
<el-form-item label="最少照片">
<el-input-number v-model="draft.minPhotoCount" :min="draft.photoRequired ? 1 : 0" :max="draft.maxPhotoCount" />
</el-form-item>
<el-form-item label="最多照片">
<el-input-number v-model="draft.maxPhotoCount" :min="Math.max(1, draft.minPhotoCount)" :max="9" />
</el-form-item>
<el-form-item label="免清洁策略">
<el-select v-model="draft.exemptPolicy">
<el-option label="不允许免清洁" value="DISABLED" />
<el-option label="开始前允许" value="BEFORE_START" />
<el-option label="活动阶段均允许" value="ANY_ACTIVE" />
</el-select>
</el-form-item>
<div class="wide-field form-actions">
<el-button type="primary" :loading="saving" @click="saveTemplate">保存模板</el-button>
<el-button @click="resetDraft">新建配置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="templates" class="data-table" empty-text="暂无清洁模板">
<el-table-column prop="scopeKey" label="范围" min-width="130" />
<el-table-column prop="name" label="模板" min-width="150" />
<el-table-column label="照片" min-width="130">
<template #default="{ row }">
{{ row.photoRequired ? `${row.minPhotoCount}${row.maxPhotoCount}` : `可选,最多 ${row.maxPhotoCount}` }}
</template>
</el-table-column>
<el-table-column label="免清洁" min-width="130">
<template #default="{ row }">{{ exemptPolicyLabel(row.exemptPolicy) }}</template>
</el-table-column>
<el-table-column label="版本" width="88">
<template #default="{ row }">v{{ row.version }}</template>
</el-table-column>
<el-table-column label="状态" width="88">
<template #default="{ row }">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</template>
</el-table-column>
<el-table-column label="操作" width="88" fixed="right">
<template #default="{ row }"><el-button link type="primary" @click="editTemplate(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</section>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import {
listCleaningTemplates,
listManagedRooms,
listManagedStores,
upsertCleaningTemplate,
type ApiSession
} from '../api';
import type {
CleaningExemptPolicy,
CleaningTemplate,
CleaningTemplateScope,
ManagedRoom,
ManagedStore
} from '../types';
const props = defineProps<{ session: ApiSession }>();
const templates = ref<CleaningTemplate[]>([]);
const stores = ref<ManagedStore[]>([]);
const rooms = ref<ManagedRoom[]>([]);
const loading = ref(false);
const saving = ref(false);
const draft = reactive({
scopeType: 'TENANT' as CleaningTemplateScope,
scopeId: '',
roomStoreId: '',
name: '默认清洁模板',
requirement: '完成桌面、地面与设备周边清洁',
photoRequired: true,
minPhotoCount: 1,
maxPhotoCount: 9,
exemptPolicy: 'ANY_ACTIVE' as CleaningExemptPolicy,
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED'
});
async function loadTemplates() {
if (!props.session.token || loading.value) return;
loading.value = true;
try {
templates.value = await listCleaningTemplates(props.session);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清洁模板加载失败');
} finally {
loading.value = false;
}
}
async function loadStores() {
try {
stores.value = await listManagedStores(props.session);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '门店列表加载失败');
}
}
async function loadRooms(storeId = draft.roomStoreId) {
if (!storeId) {
rooms.value = [];
return;
}
try {
rooms.value = await listManagedRooms(props.session, storeId);
} catch (error) {
rooms.value = [];
ElMessage.error(error instanceof Error ? error.message : '房间列表加载失败');
}
}
async function saveTemplate() {
if (!draft.name || (draft.scopeType !== 'TENANT' && !/^[1-9]\d{0,19}$/.test(draft.scopeId))) {
return ElMessage.warning('请填写模板名称和有效作用范围');
}
saving.value = true;
try {
await upsertCleaningTemplate(props.session, {
scopeType: draft.scopeType,
scopeId: draft.scopeType === 'TENANT' ? undefined : draft.scopeId,
name: draft.name,
requirement: draft.requirement,
photoRequired: draft.photoRequired,
minPhotoCount: draft.minPhotoCount,
maxPhotoCount: draft.maxPhotoCount,
exemptPolicy: draft.exemptPolicy,
status: draft.status
});
ElMessage.success('清洁模板已保存,仅影响后续新任务');
await loadTemplates();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清洁模板保存失败');
} finally {
saving.value = false;
}
}
async function editTemplate(template: CleaningTemplate) {
draft.scopeType = template.scopeType;
draft.scopeId = template.storeId || template.roomId || '';
draft.roomStoreId = template.scopeType === 'ROOM' ? template.storeId || '' : '';
if (draft.roomStoreId) await loadRooms();
draft.name = template.name;
draft.requirement = template.requirement;
draft.photoRequired = template.photoRequired;
draft.minPhotoCount = template.minPhotoCount;
draft.maxPhotoCount = template.maxPhotoCount;
draft.exemptPolicy = template.exemptPolicy;
draft.status = template.status;
}
function resetDraft() {
Object.assign(draft, {
scopeType: 'TENANT', scopeId: '', roomStoreId: '', name: '默认清洁模板',
requirement: '完成桌面、地面与设备周边清洁', photoRequired: true,
minPhotoCount: 1, maxPhotoCount: 9, exemptPolicy: 'ANY_ACTIVE', status: 'ACTIVE'
});
}
function handleScopeChange() { draft.scopeId = ''; draft.roomStoreId = ''; rooms.value = []; }
async function handleRoomStoreChange() { draft.scopeId = ''; await loadRooms(); }
function normalizePhotoRule() { draft.minPhotoCount = draft.photoRequired ? Math.max(1, draft.minPhotoCount) : 0; }
function exemptPolicyLabel(policy: CleaningExemptPolicy) {
return { DISABLED: '不允许', BEFORE_START: '开始前', ANY_ACTIVE: '活动阶段' }[policy];
}
watch(() => props.session.token, (token) => {
if (token) void Promise.all([loadTemplates(), loadStores()]);
}, { immediate: true });
</script>
<style scoped>
.rules-panel { display: grid; gap: 18px; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.panel-heading h3 { margin: 4px 0 6px; color: var(--ink); }
.panel-heading p:last-child { margin: 0; color: var(--muted); }
.rule-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 14px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
.wide-field { grid-column: 1 / -1; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 980px) { .rule-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .panel-heading { align-items: stretch; flex-direction: column; } .rule-form { grid-template-columns: 1fr; padding: 14px; } .wide-field { grid-column: auto; } .form-actions { justify-content: stretch; } .form-actions :deep(.el-button) { flex: 1; } }
</style>
+79 -13
View File
@@ -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 @@
</template>
</el-dialog>
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="760px">
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="880px">
<div v-if="detailDialog.task" class="dialog-stack">
<div class="detail-grid">
<span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span>
@@ -341,6 +341,12 @@
<el-descriptions-item label="要求" :span="2">
{{ compactText(detailDialog.task.requirement) }}
</el-descriptions-item>
<el-descriptions-item label="照片规则">
{{ detailDialog.task.photoRequired ? `${detailDialog.task.minPhotoCount}${detailDialog.task.maxPhotoCount}` : `可选,最多 ${detailDialog.task.maxPhotoCount}` }}
</el-descriptions-item>
<el-descriptions-item label="任务快照">
模板 v{{ detailDialog.task.cleaningTemplateVersion }} · 照片修订 {{ detailDialog.task.photoRevision }} · 补做 {{ detailDialog.task.reworkCount }}
</el-descriptions-item>
<el-descriptions-item label="驳回原因" :span="2">
{{ compactText(detailDialog.task.rejectReason) }}
</el-descriptions-item>
@@ -351,6 +357,46 @@
<span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span>
<span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span>
</div>
<section class="stat-block" v-loading="detailDialog.loadingSubmissions">
<header>
<h3>验收照片版本</h3>
<span>{{ detailDialog.submissions.length }} 次提交</span>
</header>
<div v-if="detailDialog.submissions.length" class="submission-list">
<article
v-for="submission in detailDialog.submissions"
:key="submission.id"
class="submission-card"
>
<header>
<strong> {{ submission.revision }} </strong>
<el-tag
:type="submission.status === 'ACCEPTED' ? 'success' : submission.status === 'REJECTED' ? 'danger' : 'warning'"
effect="light"
>
{{ submission.status }}
</el-tag>
<span>{{ shortDate(submission.submittedAt) }}</span>
</header>
<p v-if="submission.rejectReason" class="submission-reason">
驳回原因{{ submission.rejectReason }}
</p>
<p v-if="submission.note" class="submission-note">提交备注{{ submission.note }}</p>
<div v-if="submission.photoUrls.length" class="photo-grid">
<el-image
v-for="url in submission.photoUrls"
:key="`${submission.id}-${url}`"
:src="url"
fit="cover"
:preview-src-list="submission.photoUrls"
preview-teleported
/>
</div>
<el-empty v-else description="本版无照片" :image-size="48" />
</article>
</div>
<el-empty v-else description="暂无提交版本" :image-size="64" />
</section>
<section class="stat-block">
<header>
<h3>操作流水</h3>
@@ -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
])
])
]);
}
@@ -144,6 +144,9 @@
@reset-sessions="handleResetCleanerSessions"
/>
</el-tab-pane>
<el-tab-pane label="清洁规则" name="rules">
<CleaningRulesPanel :session="session" />
</el-tab-pane>
<el-tab-pane label="联调" name="handoff">
<CleaningFieldHandoffPanel />
</el-tab-pane>
@@ -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,
+37
View File
@@ -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;
+43
View File
@@ -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;
+469 -29
View File
@@ -9,6 +9,8 @@ export const cleaningTaskStatuses = [
export type CleaningTaskStatus = typeof cleaningTaskStatuses[number];
export type CleaningSettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
export type CleaningSettlementPayoutState = 'NONE' | 'SUCCESS' | 'FAIL' | 'PROCESSING' | 'WAIT_USER_CONFIRM';
export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM';
export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE';
export class CleaningTaskError extends Error {
constructor(public readonly code: string) { super(code); }
@@ -36,8 +38,16 @@ interface CleaningTaskRow extends RowDataPacket {
priority: number;
rewardCents: number;
requirement: string;
cleaningTemplateId: string | null;
cleaningTemplateVersion: number;
photoRequired: number | boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
photoUrlsJson: string | string[] | null;
rejectReason: string;
reworkCount: number;
photoRevision: number;
claimedAt: Date | null;
startedAt: Date | null;
submittedAt: Date | null;
@@ -68,10 +78,55 @@ interface CleaningTaskEventRow extends RowDataPacket {
note: string;
createdAt: Date;
}
interface CleaningTaskSubmissionRow extends RowDataPacket {
id: string;
taskId: string;
revision: number;
status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED';
photoUrlsJson: string | string[];
note: string;
rejectReason: string;
submittedBy: string;
reviewedBy: string | null;
submittedAt: Date;
reviewedAt: Date | null;
}
interface CountRow extends RowDataPacket { total: number }
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
interface AmountRow extends RowDataPacket { amount: number | null }
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null }
interface CurrentTaskRuleRow extends CurrentStatusRow {
storeId: string;
photoRequired: number | boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
reworkCount: number;
}
interface CleaningTemplateRow extends RowDataPacket {
id: string;
scopeKey: string;
scopeType: CleaningTemplateScope;
storeId: string | null;
roomId: string | null;
name: string;
requirement: string;
photoRequired: number | boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
version: number;
status: 'ACTIVE' | 'DISABLED';
updatedAt: Date;
}
interface PhotoOwnershipRow extends RowDataPacket {
id: string;
publicUrl: string;
}
interface ExpiredPhotoRow extends RowDataPacket {
id: string;
storagePath: string;
}
interface ReclaimRow extends CurrentStatusRow { id: string }
interface FinishedOrderRow extends RowDataPacket {
id: string;
@@ -167,6 +222,72 @@ interface ManagerDailyTrendRow extends RowDataPacket {
export class CleaningTaskRepository {
constructor(private readonly pool: MySqlPool) {}
async listTemplates(input: CleaningActor) {
this.assertCleaner(input.access, 'read');
const [rows] = await this.pool.execute<CleaningTemplateRow[]>(
`SELECT t.id, t.scope_key AS scopeKey, t.scope_type AS scopeType,
t.store_id AS storeId, t.room_id AS roomId, t.name, t.requirement,
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
t.version, t.status, t.updated_at AS updatedAt
FROM qipai_cleaning_templates t
WHERE t.tenant_id = ?
AND (t.scope_type = 'TENANT' OR ${storeScopeSql(input.access, 't.store_id')})
ORDER BY FIELD(t.scope_type, 'TENANT', 'STORE', 'ROOM'), t.scope_key`,
[input.tenantId]
);
return rows.map(publicTemplate);
}
async upsertTemplate(input: CleaningActor & {
scopeType: CleaningTemplateScope;
scopeId?: string;
name: string;
requirement: string;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
status: 'ACTIVE' | 'DISABLED';
}) {
this.assertCleaner(input.access, 'write');
if (input.maxPhotoCount < 1 || input.maxPhotoCount > 9
|| input.minPhotoCount < 0 || input.minPhotoCount > input.maxPhotoCount
|| (input.photoRequired && input.minPhotoCount < 1)
|| (!input.photoRequired && input.minPhotoCount !== 0)) {
throw new CleaningTaskError('CLEANING_TEMPLATE_PHOTO_RULE_INVALID');
}
const scope = await this.resolveTemplateScope(input, input.scopeType, input.scopeId);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_cleaning_templates
(tenant_id, scope_key, scope_type, store_id, room_id, name, requirement,
photo_required, min_photo_count, max_photo_count, exempt_policy, status,
created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
id = LAST_INSERT_ID(id), name = VALUES(name), requirement = VALUES(requirement),
photo_required = VALUES(photo_required), min_photo_count = VALUES(min_photo_count),
max_photo_count = VALUES(max_photo_count), exempt_policy = VALUES(exempt_policy),
status = VALUES(status), updated_by = VALUES(updated_by), version = version + 1`,
[input.tenantId, scope.scopeKey, input.scopeType, scope.storeId, scope.roomId,
input.name.slice(0, 128), input.requirement.slice(0, 512), input.photoRequired ? 1 : 0,
input.minPhotoCount, input.maxPhotoCount, input.exemptPolicy, input.status,
input.userId, input.userId]
);
const templateId = String(result.insertId);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id, trace_id, metadata)
VALUES (?, 'USER', ?, 'CLEANING_TEMPLATE_UPSERT', 'CLEANING_TEMPLATE', ?, ?,
JSON_OBJECT('scopeType', ?, 'scopeKey', ?, 'appliesToExistingTasks', FALSE))`,
[input.tenantId, input.userId, templateId, input.traceId,
input.scopeType, scope.scopeKey]
);
return this.getTemplate(connection, input.tenantId, templateId);
});
}
async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
this.assertCleaner(input.access, 'read');
const status = input.status ?? 'WAITING';
@@ -263,10 +384,9 @@ export class CleaningTaskRepository {
}
async rework(input: CleaningActor & { taskId: string }) {
return this.moveMine(input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', {
photo_urls_json: JSON.stringify([]),
reject_reason: ''
});
return this.moveMine(
input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', { incrementRework: true }
);
}
async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
@@ -289,7 +409,8 @@ export class CleaningTaskRepository {
);
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
SET rework_count = rework_count + IF(status = 'REJECTED', 1, 0),
status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
started_at = NULL, submitted_at = NULL, completed_at = NULL, settled_at = NULL,
cancelled_at = NULL, photo_urls_json = JSON_ARRAY(), reject_reason = ''
WHERE tenant_id = ? AND id = ?`,
@@ -304,9 +425,28 @@ export class CleaningTaskRepository {
}
async complete(input: CleaningActor & { taskId: string; note?: string }) {
return this.moveManaged(
input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''
);
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
await this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'COMPLETED', completed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`,
[input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_task_submissions
SET status = 'ACCEPTED', reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND task_id = ? AND status = 'SUBMITTED'`,
[input.userId, input.tenantId, input.taskId]
);
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
await this.recordEventWithConnection(
connection, input, input.taskId, 'SUBMITTED', 'COMPLETED', 'COMPLETE', input.note ?? ''
);
});
return this.getTask(input, input.taskId);
}
async reject(input: CleaningActor & { taskId: string; reason: string }) {
@@ -320,6 +460,12 @@ export class CleaningTaskRepository {
[input.reason.slice(0, 512), input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_task_submissions
SET status = 'REJECTED', reject_reason = ?, reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND task_id = ? AND status = 'SUBMITTED'`,
[input.reason.slice(0, 512), input.userId, input.tenantId, input.taskId]
);
await this.recordEventWithConnection(
connection, input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason
);
@@ -331,8 +477,11 @@ export class CleaningTaskRepository {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
await this.transaction(async (connection) => {
const [currentRows] = await connection.execute<CurrentStatusRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId
const [currentRows] = await connection.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
rework_count AS reworkCount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
FOR UPDATE`,
@@ -340,6 +489,12 @@ export class CleaningTaskRepository {
);
const current = currentRows[0];
if (!current) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
const allowedStatuses = current.exemptPolicy === 'ANY_ACTIVE'
? ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED']
: current.exemptPolicy === 'BEFORE_START' ? ['WAITING', 'CLAIMED'] : [];
if (!allowedStatuses.includes(current.status)) {
throw new CleaningTaskError('CLEANING_EXEMPT_POLICY_DENIED');
}
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'EXEMPT',
@@ -348,10 +503,8 @@ export class CleaningTaskRepository {
reject_reason = '',
completed_at = NULL,
settled_at = NULL
WHERE tenant_id = ? AND id = ?
AND status IN ('WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED')
AND deleted_at IS NULL`,
[input.tenantId, input.taskId]
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
[input.tenantId, input.taskId, current.status]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await connection.execute(
@@ -395,6 +548,24 @@ export class CleaningTaskRepository {
return rows.map(publicTaskEvent);
}
async listSubmissions(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'read');
await this.assertStoreVisible(input, input.taskId);
const [rows] = await this.pool.execute<CleaningTaskSubmissionRow[]>(
`SELECT s.id, s.task_id AS taskId, s.revision, s.status,
s.photo_urls_json AS photoUrlsJson, s.note,
s.reject_reason AS rejectReason, s.submitted_by AS submittedBy,
s.reviewed_by AS reviewedBy, s.submitted_at AS submittedAt,
s.reviewed_at AS reviewedAt
FROM qipai_cleaning_task_submissions s
WHERE s.tenant_id = ? AND s.task_id = ?
ORDER BY s.revision DESC, s.id DESC
LIMIT 20`,
[input.tenantId, input.taskId]
);
return rows.map(publicTaskSubmission);
}
async addMember(input: CleaningActor & {
taskId: string; cleanerUserId: string; rewardCents: number; note?: string;
}) {
@@ -825,26 +996,68 @@ export class CleaningTaskRepository {
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
this.assertCleaner(input.access, 'write');
if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED');
await this.transaction(async (connection) => {
const [rows] = await connection.execute<CurrentStatusRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId
const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
rework_count AS reworkCount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.taskId, input.userId]
);
const current = rows[0];
if (!current || !['STARTED', 'REJECTED'].includes(current.status)) {
if (!current || current.status !== 'STARTED') {
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
}
const photoUrls = [...new Set(input.photoUrls)];
if (photoUrls.length !== input.photoUrls.length
|| photoUrls.length < Number(current.minPhotoCount)
|| photoUrls.length > Number(current.maxPhotoCount)
|| (current.photoRequired && photoUrls.length === 0)) {
throw new CleaningTaskError('CLEANING_PHOTO_COUNT_INVALID');
}
let ownedPhotos: PhotoOwnershipRow[] = [];
if (photoUrls.length > 0) {
const placeholders = photoUrls.map(() => '?').join(',');
const [photoRows] = await connection.execute<PhotoOwnershipRow[]>(
`SELECT id, public_url AS publicUrl
FROM qipai_cleaning_task_photos
WHERE tenant_id = ? AND task_id = ? AND uploaded_by = ?
AND status = 'PENDING' AND deleted_at IS NULL
AND public_url IN (${placeholders})
FOR UPDATE`,
[input.tenantId, input.taskId, input.userId, ...photoUrls]
);
ownedPhotos = photoRows;
if (ownedPhotos.length !== photoUrls.length) {
throw new CleaningTaskError('CLEANING_PHOTO_OWNERSHIP_INVALID');
}
}
const revision = Number(current.reworkCount) + 1;
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3),
photo_urls_json = ?, reject_reason = ''
WHERE tenant_id = ? AND id = ?`,
[JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId]
[JSON.stringify(photoUrls), input.tenantId, input.taskId]
);
await connection.execute(
`INSERT INTO qipai_cleaning_task_submissions
(tenant_id, task_id, revision, status, photo_urls_json, note, submitted_by)
VALUES (?, ?, ?, 'SUBMITTED', ?, ?, ?)`,
[input.tenantId, input.taskId, revision, JSON.stringify(photoUrls),
(input.note ?? '').slice(0, 512), input.userId]
);
if (ownedPhotos.length > 0) {
await connection.execute(
`UPDATE qipai_cleaning_task_photos
SET status = 'ATTACHED', attached_revision = ?, retention_until = '9999-12-31 23:59:59.999'
WHERE tenant_id = ? AND task_id = ? AND id IN (${ownedPhotos.map(() => '?').join(',')})`,
[revision, input.tenantId, input.taskId, ...ownedPhotos.map((photo) => photo.id)]
);
}
await this.recordEventWithConnection(
connection, input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''
);
@@ -854,14 +1067,106 @@ export class CleaningTaskRepository {
async assertCanUploadPhoto(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'write');
const [rows] = await this.pool.execute<CurrentStatusRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId
const [rows] = await this.pool.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
rework_count AS reworkCount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status IN ('STARTED', 'REJECTED')
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = 'STARTED'
AND deleted_at IS NULL`,
[input.tenantId, input.taskId, input.userId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
return { storeId: String(rows[0].storeId), maxPhotoCount: Number(rows[0].maxPhotoCount) };
}
async recordPhotoUpload(input: CleaningActor & { taskId: string; image: {
storagePath: string;
publicUrl: string;
mimeType: string;
byteSize: number;
width: number;
height: number;
checksumSha256: string;
} }) {
this.assertCleaner(input.access, 'write');
return this.transaction(async (connection) => {
const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
rework_count AS reworkCount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ?
AND status = 'STARTED' AND deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.taskId, input.userId]
);
const task = rows[0];
if (!task) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
const [counts] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_task_photos
WHERE tenant_id = ? AND task_id = ? AND uploaded_by = ?
AND status = 'PENDING' AND deleted_at IS NULL`,
[input.tenantId, input.taskId, input.userId]
);
if (Number(counts[0]?.total ?? 0) >= Number(task.maxPhotoCount)) {
throw new CleaningTaskError('CLEANING_PHOTO_COUNT_INVALID');
}
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_cleaning_task_photos
(tenant_id, task_id, store_id, uploaded_by, storage_path, public_url,
mime_type, byte_size, width, height, checksum_sha256, status, retention_until)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING',
TIMESTAMPADD(DAY, 7, UTC_TIMESTAMP(3)))`,
[input.tenantId, input.taskId, task.storeId, input.userId,
input.image.storagePath, input.image.publicUrl, input.image.mimeType,
input.image.byteSize, input.image.width, input.image.height, input.image.checksumSha256]
);
return { id: String(result.insertId), ...input.image };
});
}
async claimExpiredPhotoUploads(input: CleaningActor & { limit: number }) {
this.assertCleaner(input.access, 'write');
const safeLimit = Math.max(1, Math.min(100, Math.trunc(input.limit)));
return this.transaction(async (connection) => {
const [rows] = await connection.execute<ExpiredPhotoRow[]>(
`SELECT p.id, p.storage_path AS storagePath
FROM qipai_cleaning_task_photos p
WHERE p.tenant_id = ? AND p.status IN ('PENDING', 'ORPHANED')
AND p.retention_until <= UTC_TIMESTAMP(3) AND p.deleted_at IS NULL
AND ${storeScopeSql(input.access, 'p.store_id')}
ORDER BY p.retention_until ASC, p.id ASC
LIMIT ${safeLimit} FOR UPDATE SKIP LOCKED`,
[input.tenantId]
);
if (rows.length > 0) {
await connection.execute(
`UPDATE qipai_cleaning_task_photos
SET status = 'ORPHANED', retention_until = TIMESTAMPADD(MINUTE, 5, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id IN (${rows.map(() => '?').join(',')})`,
[input.tenantId, ...rows.map((row) => row.id)]
);
}
return rows.map((row) => ({ id: String(row.id), storagePath: row.storagePath }));
});
}
async markPhotoUploadsDeleted(input: CleaningActor & { photoIds: string[] }) {
this.assertCleaner(input.access, 'write');
if (input.photoIds.length === 0) return { deleted: 0 };
const uniqueIds = [...new Set(input.photoIds)];
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_task_photos p
SET p.deleted_at = UTC_TIMESTAMP(3), p.retention_until = UTC_TIMESTAMP(3)
WHERE p.tenant_id = ? AND p.status = 'ORPHANED' AND p.deleted_at IS NULL
AND p.id IN (${uniqueIds.map(() => '?').join(',')})
AND ${storeScopeSql(input.access, 'p.store_id')}`,
[input.tenantId, ...uniqueIds]
);
return { deleted: result.affectedRows };
}
async createForFinishedOrder(
@@ -877,23 +1182,45 @@ export class CleaningTaskRepository {
);
const order = orders[0];
if (!order) throw new CleaningTaskError('CLEANING_ORDER_NOT_FINISHED');
const [templates] = await connection.execute<CleaningTemplateRow[]>(
`SELECT id, scope_key AS scopeKey, scope_type AS scopeType,
store_id AS storeId, room_id AS roomId, name, requirement,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
version, status, updated_at AS updatedAt
FROM qipai_cleaning_templates
WHERE tenant_id = ? AND status = 'ACTIVE'
AND ((scope_type = 'ROOM' AND room_id = ?)
OR (scope_type = 'STORE' AND store_id = ?)
OR scope_type = 'TENANT')
ORDER BY FIELD(scope_type, 'ROOM', 'STORE', 'TENANT')
LIMIT 1`,
[input.tenantId, order.roomId, order.storeId]
);
const template = templates[0];
const taskNo = `CLN-${order.orderNo}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_cleaning_tasks
(tenant_id, store_id, room_id, order_id, task_no, status, priority,
reward_cents, requirement, photo_urls_json)
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, '订单结束后保洁', JSON_ARRAY())`,
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo]
reward_cents, requirement, cleaning_template_id, cleaning_template_version,
photo_required, min_photo_count, max_photo_count, exempt_policy, photo_urls_json)
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, ?, ?, ?, ?, ?, ?, ?, JSON_ARRAY())`,
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo,
template?.requirement || '订单结束后清洁', template?.id ?? null,
Number(template?.version ?? 0), template ? (template.photoRequired ? 1 : 0) : 1,
Number(template?.minPhotoCount ?? 1), Number(template?.maxPhotoCount ?? 9),
template?.exemptPolicy ?? 'ANY_ACTIVE']
);
if (result.affectedRows === 1) {
await connection.execute(
`INSERT IGNORE INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
SELECT tenant_id, id, NULL, 'WAITING', 'AUTO_CREATE', ?, ?, '订单结束自动创建保洁任务',
JSON_OBJECT('orderId', ?)
JSON_OBJECT('orderId', ?, 'cleaningTemplateId', ?, 'cleaningTemplateVersion', ?)
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND order_id = ?`,
[input.actorId, input.traceId, input.orderId, input.tenantId, input.orderId]
[input.actorId, input.traceId, input.orderId, template?.id ?? null,
Number(template?.version ?? 0), input.tenantId, input.orderId]
);
}
}
@@ -1106,7 +1433,14 @@ export class CleaningTaskRepository {
t.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
t.order_id AS orderId, o.order_no AS orderNo, t.status,
t.cleaner_user_id AS cleanerUserId, t.priority, t.reward_cents AS rewardCents,
t.requirement, t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
t.requirement, t.cleaning_template_id AS cleaningTemplateId,
t.cleaning_template_version AS cleaningTemplateVersion,
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
t.rework_count AS reworkCount,
COALESCE((SELECT MAX(sub.revision) FROM qipai_cleaning_task_submissions sub
WHERE sub.tenant_id = t.tenant_id AND sub.task_id = t.id), 0) AS photoRevision,
t.claimed_at AS claimedAt, t.started_at AS startedAt,
t.submitted_at AS submittedAt, t.completed_at AS completedAt,
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
@@ -1175,7 +1509,7 @@ export class CleaningTaskRepository {
action: string,
timestampColumn: 'started_at' | 'submitted_at',
note: string,
extra?: { photo_urls_json?: string; reject_reason?: string }
extra?: { photo_urls_json?: string; reject_reason?: string; incrementRework?: boolean }
) {
this.assertCleaner(input.access, 'write');
const extraAssignments: string[] = [];
@@ -1188,6 +1522,7 @@ export class CleaningTaskRepository {
extraAssignments.push('reject_reason = ?');
extraParams.push(extra.reject_reason);
}
if (extra?.incrementRework) extraAssignments.push('rework_count = rework_count + 1');
const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : '';
await this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
@@ -1280,6 +1615,61 @@ export class CleaningTaskRepository {
);
}
private async getTemplate(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
templateId: string
) {
const [rows] = await connection.execute<CleaningTemplateRow[]>(
`SELECT id, scope_key AS scopeKey, scope_type AS scopeType,
store_id AS storeId, room_id AS roomId, name, requirement,
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
version, status, updated_at AS updatedAt
FROM qipai_cleaning_templates
WHERE tenant_id = ? AND id = ?`,
[tenantId, templateId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_TEMPLATE_NOT_FOUND');
return publicTemplate(rows[0]);
}
private async resolveTemplateScope(
input: CleaningActor,
scopeType: CleaningTemplateScope,
scopeId?: string
) {
if (scopeType === 'TENANT') {
if (scopeId || (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN'))) {
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
}
return { scopeKey: 'TENANT', storeId: null, roomId: null };
}
if (!scopeId) throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_INVALID');
if (scopeType === 'STORE') {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, scopeId]
);
if (!rows[0] || !canAccessStore(input.access, scopeId)) {
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
}
return { scopeKey: `STORE:${scopeId}`, storeId: scopeId, roomId: null };
}
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT store_id AS storeId FROM qipai_rooms
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, scopeId]
);
const storeId = rows[0]?.storeId ? String(rows[0].storeId) : '';
if (!storeId || !canAccessStore(input.access, storeId)) {
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
}
return { scopeKey: `ROOM:${scopeId}`, storeId, roomId: scopeId };
}
private async assertStoreVisible(input: CleaningActor, taskId: string) {
if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return;
const [rows] = await this.pool.execute<RowDataPacket[]>(
@@ -1417,6 +1807,12 @@ export class CleaningTaskRepository {
}
}
function canAccessStore(access: AccessProfile, storeId: string) {
return access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| access.storeIds.includes(storeId);
}
function storeScopeSql(access: AccessProfile, storeExpression: string) {
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
return '1 = 1';
@@ -1441,8 +1837,16 @@ function publicTask(row: CleaningTaskRow) {
priority: Number(row.priority),
rewardCents: Number(row.rewardCents),
requirement: row.requirement,
cleaningTemplateId: row.cleaningTemplateId === null ? null : String(row.cleaningTemplateId),
cleaningTemplateVersion: Number(row.cleaningTemplateVersion),
photoRequired: Boolean(row.photoRequired),
minPhotoCount: Number(row.minPhotoCount),
maxPhotoCount: Number(row.maxPhotoCount),
exemptPolicy: row.exemptPolicy,
photoUrls: parseJsonArray(row.photoUrlsJson),
rejectReason: row.rejectReason,
reworkCount: Number(row.reworkCount),
photoRevision: Number(row.photoRevision),
claimedAt: row.claimedAt,
startedAt: row.startedAt,
submittedAt: row.submittedAt,
@@ -1453,6 +1857,26 @@ function publicTask(row: CleaningTaskRow) {
};
}
function publicTemplate(row: CleaningTemplateRow) {
return {
id: String(row.id),
scopeKey: row.scopeKey,
scopeType: row.scopeType,
storeId: row.storeId === null ? null : String(row.storeId),
roomId: row.roomId === null ? null : String(row.roomId),
name: row.name,
requirement: row.requirement,
photoRequired: Boolean(row.photoRequired),
minPhotoCount: Number(row.minPhotoCount),
maxPhotoCount: Number(row.maxPhotoCount),
exemptPolicy: row.exemptPolicy,
version: Number(row.version),
status: row.status,
updatedAt: row.updatedAt,
appliesToExistingTasks: false
};
}
function publicTaskMember(row: CleaningTaskMemberRow) {
return {
id: String(row.id),
@@ -1481,6 +1905,22 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
};
}
function publicTaskSubmission(row: CleaningTaskSubmissionRow) {
return {
id: String(row.id),
taskId: String(row.taskId),
revision: Number(row.revision),
status: row.status,
photoUrls: parseJsonArray(row.photoUrlsJson),
note: row.note,
rejectReason: row.rejectReason,
submittedBy: String(row.submittedBy),
reviewedBy: row.reviewedBy === null ? null : String(row.reviewedBy),
submittedAt: row.submittedAt,
reviewedAt: row.reviewedAt
};
}
function publicSettlement(row: SettlementRow) {
return {
id: String(row.id),
+31 -3
View File
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { extname, resolve, sep } from 'node:path';
import sharp from 'sharp';
@@ -33,18 +33,33 @@ export class MediaStorage {
if (input.body.length === 0 || input.body.length > 8 * 1024 * 1024) {
throw new MediaValidationError('IMAGE_SIZE_INVALID');
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.contentType)) {
const declaredFormat = {
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp'
}[input.contentType];
if (!declaredFormat) {
throw new MediaValidationError('IMAGE_TYPE_INVALID');
}
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(extname(input.originalName).toLowerCase())) {
const declaredExtension = {
'.jpg': 'jpeg',
'.jpeg': 'jpeg',
'.png': 'png',
'.webp': 'webp'
}[extname(input.originalName).toLowerCase()];
if (!declaredExtension) {
throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
}
if (declaredExtension !== declaredFormat) {
throw new MediaValidationError('IMAGE_TYPE_MISMATCH');
}
let result: Buffer;
let metadata: sharp.Metadata;
try {
const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 });
metadata = await source.metadata();
if (!metadata.width || !metadata.height) throw new Error('missing dimensions');
if (metadata.format !== declaredFormat) throw new Error('declared image type mismatch');
result = await source
.rotate()
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
@@ -74,4 +89,17 @@ export class MediaStorage {
checksumSha256: createHash('sha256').update(result).digest('hex')
};
}
async deleteImage(storagePath: string): Promise<void> {
const safeRoot = resolve(this.root);
const target = resolve(safeRoot, storagePath);
if (target === safeRoot || !target.startsWith(`${safeRoot}${sep}`)) {
throw new MediaValidationError('IMAGE_PATH_INVALID');
}
try {
await unlink(target);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
}
+5 -2
View File
@@ -53,7 +53,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql'
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
'database/migrations/2026081005_m09b_cleaning_rules.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -88,9 +89,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql'
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql'
],
down: [
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
@@ -60,12 +60,17 @@ export class OrderDeviceAutomationService {
}
if (payload.event === 'ORDER_ROOM_CHANGED') {
if (payload.previousRoomId && payload.previousRoomId !== order.roomId) {
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
if (!await this.hasActiveRoomOrder(order.tenantId, payload.previousRoomId, order.id)) {
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
}
}
await this.startOrderDevices(order, payload.traceId);
return { orderId: order.id, action: 'ROOM_CHANGED' };
}
if (await this.hasActiveRoomOrder(order.tenantId, order.roomId, order.id)) {
return { orderId: order.id, skipped: true, reason: 'ROOM_HAS_ACTIVE_ORDER' };
}
await this.cancelRoomDevices(order, payload.traceId, order.roomId);
return { orderId: order.id, action: 'CANCELLED' };
}
@@ -112,6 +117,17 @@ export class OrderDeviceAutomationService {
};
}
private async hasActiveRoomOrder(tenantId: string, roomId: string, excludedOrderId: string) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_orders
WHERE tenant_id = ? AND room_id = ? AND id <> ?
AND status IN ('PAID', 'RESERVED', 'IN_PROGRESS') AND deleted_at IS NULL
LIMIT 1`,
[tenantId, roomId, excludedOrderId]
);
return Boolean(rows[0]);
}
private async loadOrder(tenantId: string, orderId: string) {
const [rows] = await this.pool.execute<OrderRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
+110 -6
View File
@@ -52,6 +52,26 @@ const memberRemoveSchema = z.object({
const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict();
const exemptSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const cleaningTemplateSchema = z.object({
scopeType: z.enum(['TENANT', 'STORE', 'ROOM']),
scopeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
name: z.string().trim().min(1).max(128),
requirement: z.string().trim().max(512).default(''),
photoRequired: z.boolean().default(true),
minPhotoCount: z.coerce.number().int().min(0).max(9).default(1),
maxPhotoCount: z.coerce.number().int().min(1).max(9).default(9),
exemptPolicy: z.enum(['DISABLED', 'BEFORE_START', 'ANY_ACTIVE']).default('ANY_ACTIVE'),
status: z.enum(['ACTIVE', 'DISABLED']).default('ACTIVE')
}).strict().superRefine((value, context) => {
if ((value.scopeType === 'TENANT') !== !value.scopeId) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['scopeId'], message: 'scope mismatch' });
}
if (value.minPhotoCount > value.maxPhotoCount
|| (value.photoRequired && value.minPhotoCount < 1)
|| (!value.photoRequired && value.minPhotoCount !== 0)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['minPhotoCount'], message: 'photo rule mismatch' });
}
});
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
const settlementPayoutStateSchema = z.enum([
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM'
@@ -101,14 +121,19 @@ const reclaimSchema = z.object({
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
limit: z.coerce.number().int().min(1).max(100).default(20)
});
const photoCleanupSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20)
}).strict();
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats' | 'managerStatistics'>;
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
| 'listTemplates' | 'upsertTemplate'
| 'stats' | 'managerStatistics'>;
mediaStorage?: MediaStorage;
payoutService?: Pick<CleaningPayoutService,
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
@@ -130,6 +155,28 @@ export async function registerCleaningRoutes(
);
}
app.get('/admin-api/cleaning/templates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listTemplates(actor),
traceId: request.traceId
}));
});
app.put('/admin-api/cleaning/templates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = cleaningTemplateSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.upsertTemplate({ ...actor, ...body.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
@@ -291,6 +338,18 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/tasks/:taskId/submissions', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listSubmissions({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -366,14 +425,25 @@ export async function registerCleaningRoutes(
return invalid(reply, request.traceId);
}
return handle(reply, request.traceId, async () => {
await options.repository.assertCanUploadPhoto({ ...actor, taskId: params.data.taskId });
const context = await options.repository.assertCanUploadPhoto({
...actor, taskId: params.data.taskId
});
const image = await options.mediaStorage!.storeImage({
tenantId: actor.tenantId,
storeId: context.storeId,
originalName,
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
body: request.body as Buffer
});
return reply.status(201).send({ code: 0, data: image, traceId: request.traceId });
try {
const recorded = await options.repository.recordPhotoUpload({
...actor, taskId: params.data.taskId, image
});
return reply.status(201).send({ code: 0, data: recorded, traceId: request.traceId });
} catch (error) {
await options.mediaStorage!.deleteImage(image.storagePath);
throw error;
}
});
});
@@ -473,6 +543,41 @@ export async function registerCleaningRoutes(
}));
});
app.post('/admin-api/cleaning/photos/cleanup', async (request, reply) => {
if (!options.mediaStorage) return reply.status(501).send({
code: 'CLEANING_PHOTO_CLEANUP_UNAVAILABLE',
message: 'Cleaning photo storage is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = photoCleanupSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
const claimed = await options.repository.claimExpiredPhotoUploads({
...actor, limit: body.data.limit
});
const deletedIds: string[] = [];
const failedIds: string[] = [];
for (const photo of claimed) {
try {
await options.mediaStorage!.deleteImage(photo.storagePath);
deletedIds.push(photo.id);
} catch {
failedIds.push(photo.id);
}
}
const result = await options.repository.markPhotoUploadsDeleted({
...actor, photoIds: deletedIds
});
return {
code: 0,
data: { claimed: claimed.length, deleted: result.deleted, failedIds },
traceId: request.traceId
};
});
});
app.post('/admin-api/cleaning/settlements', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -679,8 +784,7 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
&& !(error instanceof CleaningPayoutError)
&& !(error instanceof WechatPayError)) throw error;
const code = error.code;
const statusCode = code === 'CLEANING_TASK_FORBIDDEN'
|| code === 'CLEANING_SETTLEMENT_FORBIDDEN' ? 403 : 409;
const statusCode = code.endsWith('_FORBIDDEN') ? 403 : 409;
return reply.status(statusCode).send({
code,
message: 'The cleaning task request cannot be completed.',
+91 -5
View File
@@ -77,6 +77,25 @@ const app = await buildApp({
},
async assertCanUploadPhoto(input) {
calls.push(['assertCanUploadPhoto', input]);
return { storeId: '11', maxPhotoCount: 3 };
},
async recordPhotoUpload(input) {
calls.push(['recordPhotoUpload', input]);
return { id: '701', ...input.image };
},
async listTemplates(input) {
calls.push(['listTemplates', input]);
return [{
id: '801', scopeKey: 'STORE:11', scopeType: 'STORE', storeId: '11', roomId: null,
name: '门店清洁', requirement: '桌面与地面', photoRequired: true,
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START',
version: 2, status: 'ACTIVE', appliesToExistingTasks: false
}];
},
async upsertTemplate(input) {
calls.push(['upsertTemplate', input]);
return { id: '801', scopeKey: `STORE:${input.scopeId}`, ...input,
storeId: input.scopeId, roomId: null, version: 3, appliesToExistingTasks: false };
},
async submit(input) {
calls.push(['submit', input]);
@@ -116,6 +135,15 @@ const app = await buildApp({
createdAt: new Date()
}];
},
async listSubmissions(input) {
calls.push(['listSubmissions', input]);
return [{
id: '9101', taskId: input.taskId, revision: 2, status: 'REJECTED',
photoUrls: ['https://api.txyundm.cn/uploads/old.webp'], note: 'first',
rejectReason: 'photo is unclear', submittedBy: '31', reviewedBy: '31',
submittedAt: new Date(), reviewedAt: new Date()
}];
},
async addMember(input) {
calls.push(['addMember', input]);
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
@@ -171,6 +199,14 @@ const app = await buildApp({
calls.push(['reclaimTimeouts', input]);
return { reclaimed: 2, taskIds: ['101', '102'] };
},
async claimExpiredPhotoUploads(input) {
calls.push(['claimExpiredPhotoUploads', input]);
return [{ id: '701', storagePath: 'tenants/7/stores/11/orphan.webp' }];
},
async markPhotoUploadsDeleted(input) {
calls.push(['markPhotoUploadsDeleted', input]);
return { deleted: input.photoIds.length };
},
async stats(input) {
calls.push(['stats', input]);
return {
@@ -281,19 +317,44 @@ const app = await buildApp({
async storeImage(input) {
calls.push(['storeImage', input]);
return {
storagePath: 'tenants/7/shared/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp',
storagePath: 'tenants/7/stores/11/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp',
mimeType: 'image/webp',
byteSize: input.body.length,
width: 640,
height: 480,
checksumSha256: 'abc'
};
},
async deleteImage(storagePath) {
calls.push(['deleteImage', storagePath]);
}
}
}
});
const templates = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/templates',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(templates.statusCode, 200);
assert.equal(templates.json().data[0].scopeKey, 'STORE:11');
const updatedTemplate = await app.inject({
method: 'PUT',
url: '/admin-api/cleaning/templates',
headers: { authorization: `Bearer ${token}` },
payload: {
scopeType: 'STORE', scopeId: '11', name: '门店清洁', requirement: '桌面与地面',
photoRequired: true, minPhotoCount: 2, maxPhotoCount: 3,
exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
}
});
assert.equal(updatedTemplate.statusCode, 200);
assert.equal(updatedTemplate.json().data.appliesToExistingTasks, false);
assert.equal(calls.at(-1)[0], 'upsertTemplate');
const hall = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
@@ -396,9 +457,11 @@ const photo = await app.inject({
payload: Buffer.from('fake-image')
});
assert.equal(photo.statusCode, 201);
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp');
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-1)[0], 'storeImage');
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp');
assert.equal(calls.at(-3)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-2)[0], 'storeImage');
assert.equal(calls.at(-2)[1].storeId, '11');
assert.equal(calls.at(-1)[0], 'recordPhotoUpload');
const submit = await app.inject({
method: 'POST',
@@ -498,6 +561,16 @@ assert.equal(calls.at(-1)[0], 'listEvents');
assert.equal(calls.at(-1)[1].taskId, '101');
assert.equal(events.json().data[0].action, 'SUBMIT');
const submissions = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/tasks/101/submissions',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(submissions.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listSubmissions');
assert.equal(submissions.json().data[0].revision, 2);
assert.equal(submissions.json().data[0].photoUrls[0], 'https://api.txyundm.cn/uploads/old.webp');
const addedMember = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/members',
@@ -664,6 +737,19 @@ assert.equal(reclaimed.statusCode, 200);
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
const cleanedPhotos = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/photos/cleanup',
headers: { authorization: `Bearer ${token}` },
payload: { limit: 10 }
});
assert.equal(cleanedPhotos.statusCode, 200);
assert.deepEqual(cleanedPhotos.json().data, { claimed: 1, deleted: 1, failedIds: [] });
assert.equal(calls.at(-3)[0], 'claimExpiredPhotoUploads');
assert.equal(calls.at(-2)[0], 'deleteImage');
assert.equal(calls.at(-1)[0], 'markPhotoUploadsDeleted');
assert.deepEqual(calls.at(-1)[1].photoIds, ['701']);
const stats = await app.inject({
method: 'GET',
url: '/app-api/cleaning/stats',
+18 -1
View File
@@ -21,7 +21,18 @@ try {
assert.equal(image.mimeType, 'image/webp');
assert.ok(image.width <= 1920);
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/);
assert.ok((await readFile(join(root, ...image.storagePath.split('/')))).length > 0);
const storedPath = join(root, ...image.storagePath.split('/'));
const storedBytes = await readFile(storedPath);
assert.ok(storedBytes.length > 0);
const storedMetadata = await sharp(storedBytes).metadata();
assert.equal(storedMetadata.exif, undefined);
assert.equal(storedMetadata.icc, undefined);
await assert.rejects(
() => storage.storeImage({
tenantId: '7', originalName: 'spoofed.jpg', contentType: 'image/jpeg', body: png
}),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_DECODE_FAILED'
);
await assert.rejects(
() => storage.storeImage({
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
@@ -29,6 +40,12 @@ try {
}),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID'
);
await storage.deleteImage(image.storagePath);
await assert.rejects(() => readFile(storedPath), (error) => error.code === 'ENOENT');
await assert.rejects(
() => storage.deleteImage('../outside.webp'),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_PATH_INVALID'
);
} finally {
await rm(root, { recursive: true, force: true });
}
+19 -1
View File
@@ -108,6 +108,9 @@ const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_l
const adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.sql');
const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_password_auth.verify.sql');
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -489,7 +492,22 @@ assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
assert.match(adminAuthUpSql, /'2026081004'/);
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/);
for (const table of [
'qipai_cleaning_templates', 'qipai_cleaning_task_photos', 'qipai_cleaning_task_submissions'
]) {
assert.match(cleaningRulesUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(cleaningRulesDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(cleaningRulesVerifySql, new RegExp(`'${table}'`));
}
for (const column of [
'cleaning_template_id', 'photo_required', 'min_photo_count', 'max_photo_count',
'exempt_policy', 'rework_count'
]) assert.match(cleaningRulesUpSql, new RegExp(`ADD COLUMN ${column}`));
assert.match(cleaningRulesUpSql, /uq_qipai_cleaning_submission_revision/);
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
assert.match(cleaningRulesVerifySql, /'2026081005'/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
console.log('PASS: M01-B through M08-D migration contracts are present.');
console.log('PASS: M01-B through M09-B migration contracts are present.');
+4 -2
View File
@@ -43,7 +43,8 @@ assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql$/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -51,7 +52,8 @@ const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
const calls = [];
const fakePool = {
+150 -11
View File
@@ -60,6 +60,9 @@ const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_cleaning_task_photos',
'qipai_cleaning_task_submissions',
'qipai_cleaning_templates',
'qipai_collection_accounts',
'qipai_device_alerts',
'qipai_device_channels',
@@ -139,13 +142,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004']
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
);
return rows;
}
@@ -1833,7 +1836,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081004');
assert.equal(overview.latestMigration.version, '2026081005');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -2528,6 +2531,19 @@ async function assertCleaningTaskTransactions(pool, context) {
[context.tenantId, taskId, userId, memberRole, rewardCents, removedAt, settledAt]
);
};
const recordPhoto = async (taskId, userId, suffix) => repository.recordPhotoUpload({
...cleanerActor(userId, `m09b-photo-${suffix}`),
taskId,
image: {
storagePath: `tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
mimeType: 'image/webp',
byteSize: 1024,
width: 1280,
height: 720,
checksumSha256: suffix.padEnd(64, '0').slice(0, 64)
}
});
const rollbackTaskId = await insertTask();
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
@@ -2650,9 +2666,10 @@ async function assertCleaningTaskTransactions(pool, context) {
await repository.start({
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
});
const firstPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-first');
await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId,
photoUrls: ['https://api.txyundm.cn/uploads/m09a-first.webp'], note: 'first submit'
photoUrls: [firstPhoto.publicUrl], note: 'first submit'
});
await repository.reject({
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
@@ -2666,9 +2683,10 @@ async function assertCleaningTaskTransactions(pool, context) {
await repository.rework({
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
});
const secondPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-second');
await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId,
photoUrls: ['https://api.txyundm.cn/uploads/m09a-second.webp'], note: 'second submit'
photoUrls: [secondPhoto.publicUrl], note: 'second submit'
});
const completeInput = {
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
@@ -2688,6 +2706,74 @@ async function assertCleaningTaskTransactions(pool, context) {
status: lifecycleRows[0].status,
completedSet: Number(lifecycleRows[0].completedSet)
}, { status: 'COMPLETED', completedSet: 1 });
const [submissionRows] = await pool.query(
`SELECT revision, status, JSON_UNQUOTE(JSON_EXTRACT(photo_urls_json, '$[0]')) AS photoUrl,
reject_reason AS rejectReason
FROM qipai_cleaning_task_submissions
WHERE tenant_id = ? AND task_id = ? ORDER BY revision`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(submissionRows.map((row) => ({
revision: Number(row.revision), status: row.status,
photoUrl: row.photoUrl, rejectReason: row.rejectReason
})), [
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' },
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' }
]);
const [photoRows] = await pool.query(
`SELECT public_url AS publicUrl, status, attached_revision AS attachedRevision
FROM qipai_cleaning_task_photos
WHERE tenant_id = ? AND task_id = ? ORDER BY attached_revision`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(photoRows.map((row) => ({
publicUrl: row.publicUrl, status: row.status, revision: Number(row.attachedRevision)
})), [
{ publicUrl: firstPhoto.publicUrl, status: 'ATTACHED', revision: 2 },
{ publicUrl: secondPhoto.publicUrl, status: 'ATTACHED', revision: 3 }
]);
const submissionHistory = await repository.listSubmissions({
...managerActor('m09b-submission-history'), taskId: lifecycleTaskId
});
assert.deepEqual(submissionHistory.map((submission) => ({
revision: submission.revision,
status: submission.status,
photoUrl: submission.photoUrls[0],
rejectReason: submission.rejectReason
})), [
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' },
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' }
]);
const cleanupTaskId = await insertTask({
status: 'STARTED', cleanerUserId: firstCleanerId,
claimedAt: new Date(), startedAt: new Date()
});
const expiredPhoto = await recordPhoto(cleanupTaskId, firstCleanerId, 'm09b-expired');
await pool.query(
`UPDATE qipai_cleaning_task_photos SET retention_until = TIMESTAMPADD(DAY, -1, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, expiredPhoto.id]
);
const expiredClaims = await repository.claimExpiredPhotoUploads({
...managerActor('m09b-photo-cleanup-claim'), limit: 10
});
assert.deepEqual(expiredClaims, [{ id: expiredPhoto.id, storagePath: expiredPhoto.storagePath }]);
assert.deepEqual(
await repository.markPhotoUploadsDeleted({
...managerActor('m09b-photo-cleanup-delete'), photoIds: [expiredPhoto.id]
}),
{ deleted: 1 }
);
const [deletedPhotoRows] = await pool.query(
`SELECT status, deleted_at IS NOT NULL AS deleted
FROM qipai_cleaning_task_photos WHERE tenant_id = ? AND id = ?`,
[context.tenantId, expiredPhoto.id]
);
assert.deepEqual(
{ status: deletedPhotoRows[0].status, deleted: Number(deletedPhotoRows[0].deleted) },
{ status: 'ORPHANED', deleted: 1 }
);
const [lifecycleEventRows] = await pool.query(
`SELECT action FROM qipai_cleaning_task_events
WHERE tenant_id = ? AND task_id = ? ORDER BY id`,
@@ -2782,6 +2868,26 @@ async function assertCleaningTaskTransactions(pool, context) {
assert.equal(settledMemberRows[0].removedAt, null);
assert.equal(Number(settledMemberRows[0].settled), 1);
const deniedExemptTaskId = await insertTask();
await pool.query(
`UPDATE qipai_cleaning_tasks SET exempt_policy = 'DISABLED'
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, deniedExemptTaskId]
);
await assert.rejects(
() => repository.exempt({
...managerActor('m09b-exempt-denied'), taskId: deniedExemptTaskId, note: 'not allowed'
}),
(error) => error instanceof CleaningTaskError && error.code === 'CLEANING_EXEMPT_POLICY_DENIED'
);
const template = await repository.upsertTemplate({
...managerActor('m09b-template-v1'), scopeType: 'TENANT', name: 'M09-B tenant default',
requirement: '两张照片并在开始前决定免清洁', photoRequired: true,
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
});
assert.equal(template.version, 1);
const [orderResult] = await pool.query(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
@@ -2812,7 +2918,10 @@ async function assertCleaningTaskTransactions(pool, context) {
orderConnection.release();
}
const [generatedRows] = await pool.query(
`SELECT t.status,
`SELECT t.status, t.cleaning_template_id AS cleaningTemplateId,
t.cleaning_template_version AS cleaningTemplateVersion,
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id
AND e.action = 'AUTO_CREATE') AS eventCount
@@ -2822,10 +2931,34 @@ async function assertCleaningTaskTransactions(pool, context) {
);
assert.equal(generatedRows.length, 1);
assert.equal(generatedRows[0].status, 'WAITING');
assert.equal(String(generatedRows[0].cleaningTemplateId), template.id);
assert.equal(Number(generatedRows[0].cleaningTemplateVersion), 1);
assert.equal(Number(generatedRows[0].photoRequired), 1);
assert.equal(Number(generatedRows[0].minPhotoCount), 2);
assert.equal(Number(generatedRows[0].maxPhotoCount), 3);
assert.equal(generatedRows[0].exemptPolicy, 'BEFORE_START');
assert.equal(Number(generatedRows[0].eventCount), 1);
const updatedTemplate = await repository.upsertTemplate({
...managerActor('m09b-template-v2'), scopeType: 'TENANT', name: 'M09-B tenant default',
requirement: '新任务无需照片', photoRequired: false,
minPhotoCount: 0, maxPhotoCount: 2, exemptPolicy: 'DISABLED', status: 'ACTIVE'
});
assert.equal(updatedTemplate.version, 2);
const [snapshotRows] = await pool.query(
`SELECT cleaning_template_version AS version, min_photo_count AS minPhotoCount,
exempt_policy AS exemptPolicy
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, finishedOrderId]
);
assert.deepEqual({
version: Number(snapshotRows[0].version),
minPhotoCount: Number(snapshotRows[0].minPhotoCount),
exemptPolicy: snapshotRows[0].exemptPolicy
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
console.log(
'PASS: M09-A cleaning claims, lifecycle events, rollback, reassignment, timeout reclaim and order generation are transactional.'
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
);
}
@@ -2875,7 +3008,8 @@ try {
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2904,7 +3038,7 @@ try {
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M06-C migration tables.');
console.log('PASS: down removed all M01-B through M09-B migration tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -2932,7 +3066,8 @@ try {
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -3069,7 +3204,11 @@ try {
'idempotent cleaning completion trace',
'SKIP LOCKED cleaning timeout reclaim batch',
'settled cleaning member preservation',
'finished order creates one cleaning task'
'finished order creates one cleaning task',
'cleaning template snapshot remains stable after config version change',
'rejected and accepted cleaning photo revisions remain distinguishable',
'expired unattached cleaning photo retention cleanup',
'task-level cleaning exemption policy denial'
]
}, null, 2));
} finally {
@@ -6,6 +6,7 @@ import {
import { DeviceControlError } from '../dist/devices/device-control-service.js';
const calls = [];
let activeRoomOrder = false;
let currentOrder = {
id: 31,
tenantId: 7,
@@ -17,6 +18,11 @@ let currentOrder = {
};
const service = new OrderDeviceAutomationService({
async execute(sql, params) {
if (sql.includes('id <> ?')) {
assert.equal(params[0], '7');
assert.equal(params[2], '31');
return [activeRoomOrder ? [{ id: 32 }] : [], []];
}
if (sql.includes('FROM qipai_orders')) {
assert.equal(params[0], '7');
assert.equal(params[1], '31');
@@ -91,6 +97,17 @@ await service.handleTask(task({
assert.equal(calls.at(-2)[0], 'cancelTask');
assert.equal(calls.at(-1)[2].on, false);
currentOrder = { ...currentOrder, status: 'FINISHED' };
activeRoomOrder = true;
const callCountBeforeProtectedFinish = calls.length;
const protectedFinish = await service.handleTask(task({
tenantId: '7', orderId: '31', event: 'ORDER_FINISHED', traceId: 'm09b-finish'
}));
assert.equal(protectedFinish.skipped, true);
assert.equal(protectedFinish.reason, 'ROOM_HAS_ACTIVE_ORDER');
assert.equal(calls.length, callCountBeforeProtectedFinish);
activeRoomOrder = false;
currentOrder = { ...currentOrder, roomId: 52, status: 'IN_PROGRESS' };
const changed = await service.handleTask(task({
tenantId: '7',
@@ -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;
@@ -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');
@@ -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';
+15 -5
View File
@@ -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}`,
}
}
+2 -2
View File
@@ -69,8 +69,8 @@
<button wx:if="{{item.canStart}}" data-task-id="{{item.id}}" bindtap="startTask">开始</button>
<button wx:if="{{item.canRework}}" data-task-id="{{item.id}}" bindtap="reworkTask">重做</button>
<view wx:if="{{item.canUpload}}" class="submit-box">
<button data-task-id="{{item.id}}" bindtap="choosePhotos">选择照片</button>
<view class="muted">至少 1 张,最多 9 张</view>
<button data-task-id="{{item.id}}" data-max-photos="{{item.maxPhotoCount}}" bindtap="choosePhotos">选择照片</button>
<view class="muted">{{item.photoRuleText}}</view>
<button data-task-id="{{item.id}}" bindtap="submitTask">提交验收</button>
</view>
</view>
+99
View File
@@ -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.');
+2
View File
@@ -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