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, CleaningSettlementDetail,
CleaningStatistics, CleaningStatistics,
CleaningTask, CleaningTask,
CleaningTemplate,
CleaningTemplateScope,
CleaningExemptPolicy,
CleaningTaskEvent, CleaningTaskEvent,
CleaningTaskMember, CleaningTaskMember,
CleaningTaskSubmission,
DecorationComponent, DecorationComponent,
DecorationVersion, DecorationVersion,
FranchiseApplication, FranchiseApplication,
@@ -449,6 +453,29 @@ export function getCleaningStatistics(
return request<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`); 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( export function listStaffUsers(
session: ApiSession, session: ApiSession,
input: { 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( export function addCleaningTaskMember(
session: ApiSession, session: ApiSession,
taskId: string, 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" size="small"
type="warning" type="warning"
:icon="Ban" :icon="Ban"
:disabled="!canExempt(row.status)" :disabled="!canExempt(row)"
@click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })" @click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })"
> >
{{ '\u514d\u6e05\u6d01' }} {{ '\u514d\u6e05\u6d01' }}
@@ -323,7 +323,7 @@
</template> </template>
</el-dialog> </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 v-if="detailDialog.task" class="dialog-stack">
<div class="detail-grid"> <div class="detail-grid">
<span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span> <span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span>
@@ -341,6 +341,12 @@
<el-descriptions-item label="要求" :span="2"> <el-descriptions-item label="要求" :span="2">
{{ compactText(detailDialog.task.requirement) }} {{ compactText(detailDialog.task.requirement) }}
</el-descriptions-item> </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"> <el-descriptions-item label="驳回原因" :span="2">
{{ compactText(detailDialog.task.rejectReason) }} {{ compactText(detailDialog.task.rejectReason) }}
</el-descriptions-item> </el-descriptions-item>
@@ -351,6 +357,46 @@
<span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span> <span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span>
<span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span> <span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span>
</div> </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"> <section class="stat-block">
<header> <header>
<h3>操作流水</h3> <h3>操作流水</h3>
@@ -451,6 +497,7 @@ import {
addCleaningTaskMember, addCleaningTaskMember,
listCleaningTaskEvents, listCleaningTaskEvents,
listCleaningTaskMembers, listCleaningTaskMembers,
listCleaningTaskSubmissions,
removeCleaningTaskMember removeCleaningTaskMember
} from '../api'; } from '../api';
import { compactText, money, shortDate } from '../format'; import { compactText, money, shortDate } from '../format';
@@ -458,6 +505,7 @@ import type {
CleaningTask, CleaningTask,
CleaningTaskEvent, CleaningTaskEvent,
CleaningTaskMember, CleaningTaskMember,
CleaningTaskSubmission,
ManagedUser, ManagedUser,
PageResult, PageResult,
TaskStatus TaskStatus
@@ -510,16 +558,20 @@ const detailDialog = reactive<{
open: boolean; open: boolean;
loadingEvents: boolean; loadingEvents: boolean;
loadingMembers: boolean; loadingMembers: boolean;
loadingSubmissions: boolean;
task: CleaningTask | null; task: CleaningTask | null;
events: CleaningTaskEvent[]; events: CleaningTaskEvent[];
members: CleaningTaskMember[]; members: CleaningTaskMember[];
submissions: CleaningTaskSubmission[];
}>({ }>({
open: false, open: false,
loadingEvents: false, loadingEvents: false,
loadingMembers: false, loadingMembers: false,
loadingSubmissions: false,
task: null, task: null,
events: [], events: [],
members: [] members: [],
submissions: []
}); });
const memberDialog = reactive({ const memberDialog = reactive({
open: false, open: false,
@@ -536,7 +588,7 @@ const selectedSubmittedTasks = computed(() => selectedTasks.value
.filter((task) => task.status === 'SUBMITTED')); .filter((task) => task.status === 'SUBMITTED'));
const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length); const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length);
const selectedExemptableTasks = computed(() => selectedTasks.value const selectedExemptableTasks = computed(() => selectedTasks.value
.filter((task) => canExempt(task.status))); .filter((task) => canExempt(task)));
const selectedExemptableCount = computed(() => selectedExemptableTasks.value.length); const selectedExemptableCount = computed(() => selectedExemptableTasks.value.length);
watch( watch(
@@ -553,11 +605,13 @@ function taskTag(status: TaskStatus) {
} }
function canSelectTask(row: CleaningTask) { function canSelectTask(row: CleaningTask) {
return row.status === 'SUBMITTED' || canExempt(row.status); return row.status === 'SUBMITTED' || canExempt(row);
} }
function canExempt(status: TaskStatus) { function canExempt(task: CleaningTask) {
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(status); 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[]) { function handleSelectionChange(rows: CleaningTask[]) {
@@ -583,20 +637,25 @@ async function openDetail(row: CleaningTask) {
detailDialog.task = row; detailDialog.task = row;
detailDialog.events = []; detailDialog.events = [];
detailDialog.members = []; detailDialog.members = [];
detailDialog.submissions = [];
detailDialog.loadingEvents = true; detailDialog.loadingEvents = true;
detailDialog.loadingMembers = true; detailDialog.loadingMembers = true;
detailDialog.loadingSubmissions = true;
try { try {
const [events, members] = await Promise.all([ const [events, members, submissions] = await Promise.all([
listCleaningTaskEvents(props.session, row.id), listCleaningTaskEvents(props.session, row.id),
listCleaningTaskMembers(props.session, row.id) listCleaningTaskMembers(props.session, row.id),
listCleaningTaskSubmissions(props.session, row.id)
]); ]);
detailDialog.events = events; detailDialog.events = events;
detailDialog.members = members; detailDialog.members = members;
detailDialog.submissions = submissions;
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败'); ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败');
} finally { } finally {
detailDialog.loadingEvents = false; detailDialog.loadingEvents = false;
detailDialog.loadingMembers = false; detailDialog.loadingMembers = false;
detailDialog.loadingSubmissions = false;
} }
} }
@@ -779,10 +838,17 @@ function exportTaskDetail() {
`${event.action}:${shortDate(event.createdAt)}`, `${event.action}:${shortDate(event.createdAt)}`,
`${compactText(event.fromStatus)} -> ${event.toStatus} ${compactText(event.actorId)} ${compactText(event.note)}` `${compactText(event.fromStatus)} -> ${event.toStatus} ${compactText(event.actorId)} ${compactText(event.note)}`
]), ]),
...task.photoUrls.map((url, index) => [ ...detailDialog.submissions.flatMap((submission) => [
'photo', [
`photo_${index + 1}`, 'submission',
url `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" @reset-sessions="handleResetCleanerSessions"
/> />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="清洁规则" name="rules">
<CleaningRulesPanel :session="session" />
</el-tab-pane>
<el-tab-pane label="联调" name="handoff"> <el-tab-pane label="联调" name="handoff">
<CleaningFieldHandoffPanel /> <CleaningFieldHandoffPanel />
</el-tab-pane> </el-tab-pane>
@@ -159,6 +162,7 @@ import CleaningTasksPanel from './CleaningTasksPanel.vue';
import CleaningSettlementsPanel from './CleaningSettlementsPanel.vue'; import CleaningSettlementsPanel from './CleaningSettlementsPanel.vue';
import CleaningStatisticsPanel from './CleaningStatisticsPanel.vue'; import CleaningStatisticsPanel from './CleaningStatisticsPanel.vue';
import CleanersPanel from './CleanersPanel.vue'; import CleanersPanel from './CleanersPanel.vue';
import CleaningRulesPanel from './CleaningRulesPanel.vue';
import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue'; import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
import { import {
ApiError, ApiError,
+37
View File
@@ -1291,6 +1291,43 @@ textarea {
background: #f5f8fb; 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 { .trend-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
+43
View File
@@ -614,8 +614,16 @@ export interface CleaningTask {
priority: number; priority: number;
rewardCents: number; rewardCents: number;
requirement: string; requirement: string;
cleaningTemplateId: string | null;
cleaningTemplateVersion: number;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
photoUrls: string[]; photoUrls: string[];
rejectReason: string; rejectReason: string;
reworkCount: number;
photoRevision: number;
claimedAt?: string; claimedAt?: string;
startedAt?: string; startedAt?: string;
submittedAt?: string; submittedAt?: string;
@@ -625,6 +633,27 @@ export interface CleaningTask {
updatedAt?: string; 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 { export interface CleaningTaskMember {
id: string; id: string;
taskId: string; taskId: string;
@@ -649,6 +678,20 @@ export interface CleaningTaskEvent {
createdAt: string; 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 { export interface CleaningSettlement {
id: string; id: string;
settlementNo: string; settlementNo: string;
+469 -29
View File
@@ -9,6 +9,8 @@ export const cleaningTaskStatuses = [
export type CleaningTaskStatus = typeof cleaningTaskStatuses[number]; export type CleaningTaskStatus = typeof cleaningTaskStatuses[number];
export type CleaningSettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED'; export type CleaningSettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
export type CleaningSettlementPayoutState = 'NONE' | 'SUCCESS' | 'FAIL' | 'PROCESSING' | 'WAIT_USER_CONFIRM'; 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 { export class CleaningTaskError extends Error {
constructor(public readonly code: string) { super(code); } constructor(public readonly code: string) { super(code); }
@@ -36,8 +38,16 @@ interface CleaningTaskRow extends RowDataPacket {
priority: number; priority: number;
rewardCents: number; rewardCents: number;
requirement: string; requirement: string;
cleaningTemplateId: string | null;
cleaningTemplateVersion: number;
photoRequired: number | boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
photoUrlsJson: string | string[] | null; photoUrlsJson: string | string[] | null;
rejectReason: string; rejectReason: string;
reworkCount: number;
photoRevision: number;
claimedAt: Date | null; claimedAt: Date | null;
startedAt: Date | null; startedAt: Date | null;
submittedAt: Date | null; submittedAt: Date | null;
@@ -68,10 +78,55 @@ interface CleaningTaskEventRow extends RowDataPacket {
note: string; note: string;
createdAt: Date; 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 CountRow extends RowDataPacket { total: number }
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number } interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
interface AmountRow extends RowDataPacket { amount: number | null } interface AmountRow extends RowDataPacket { amount: number | null }
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | 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 ReclaimRow extends CurrentStatusRow { id: string }
interface FinishedOrderRow extends RowDataPacket { interface FinishedOrderRow extends RowDataPacket {
id: string; id: string;
@@ -167,6 +222,72 @@ interface ManagerDailyTrendRow extends RowDataPacket {
export class CleaningTaskRepository { export class CleaningTaskRepository {
constructor(private readonly pool: MySqlPool) {} 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 }) { async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
this.assertCleaner(input.access, 'read'); this.assertCleaner(input.access, 'read');
const status = input.status ?? 'WAITING'; const status = input.status ?? 'WAITING';
@@ -263,10 +384,9 @@ export class CleaningTaskRepository {
} }
async rework(input: CleaningActor & { taskId: string }) { async rework(input: CleaningActor & { taskId: string }) {
return this.moveMine(input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', { return this.moveMine(
photo_urls_json: JSON.stringify([]), input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', { incrementRework: true }
reject_reason: '' );
});
} }
async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) { async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
@@ -289,7 +409,8 @@ export class CleaningTaskRepository {
); );
await connection.execute( await connection.execute(
`UPDATE qipai_cleaning_tasks `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, started_at = NULL, submitted_at = NULL, completed_at = NULL, settled_at = NULL,
cancelled_at = NULL, photo_urls_json = JSON_ARRAY(), reject_reason = '' cancelled_at = NULL, photo_urls_json = JSON_ARRAY(), reject_reason = ''
WHERE tenant_id = ? AND id = ?`, WHERE tenant_id = ? AND id = ?`,
@@ -304,9 +425,28 @@ export class CleaningTaskRepository {
} }
async complete(input: CleaningActor & { taskId: string; note?: string }) { async complete(input: CleaningActor & { taskId: string; note?: string }) {
return this.moveManaged( this.assertCleaner(input.access, 'write');
input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? '' 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 }) { async reject(input: CleaningActor & { taskId: string; reason: string }) {
@@ -320,6 +460,12 @@ export class CleaningTaskRepository {
[input.reason.slice(0, 512), input.tenantId, input.taskId] [input.reason.slice(0, 512), input.tenantId, input.taskId]
); );
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); 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( await this.recordEventWithConnection(
connection, input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason connection, input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason
); );
@@ -331,8 +477,11 @@ export class CleaningTaskRepository {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId); await this.assertStoreVisible(input, input.taskId);
await this.transaction(async (connection) => { await this.transaction(async (connection) => {
const [currentRows] = await connection.execute<CurrentStatusRow[]>( const [currentRows] = await connection.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId `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 FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
FOR UPDATE`, FOR UPDATE`,
@@ -340,6 +489,12 @@ export class CleaningTaskRepository {
); );
const current = currentRows[0]; const current = currentRows[0];
if (!current) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND'); 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>( const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks `UPDATE qipai_cleaning_tasks
SET status = 'EXEMPT', SET status = 'EXEMPT',
@@ -348,10 +503,8 @@ export class CleaningTaskRepository {
reject_reason = '', reject_reason = '',
completed_at = NULL, completed_at = NULL,
settled_at = NULL settled_at = NULL
WHERE tenant_id = ? AND id = ? WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
AND status IN ('WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED') [input.tenantId, input.taskId, current.status]
AND deleted_at IS NULL`,
[input.tenantId, input.taskId]
); );
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await connection.execute( await connection.execute(
@@ -395,6 +548,24 @@ export class CleaningTaskRepository {
return rows.map(publicTaskEvent); 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 & { async addMember(input: CleaningActor & {
taskId: string; cleanerUserId: string; rewardCents: number; note?: string; 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 }) { async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED');
await this.transaction(async (connection) => { await this.transaction(async (connection) => {
const [rows] = await connection.execute<CurrentStatusRow[]>( const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId `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 FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
FOR UPDATE`, FOR UPDATE`,
[input.tenantId, input.taskId, input.userId] [input.tenantId, input.taskId, input.userId]
); );
const current = rows[0]; const current = rows[0];
if (!current || !['STARTED', 'REJECTED'].includes(current.status)) { if (!current || current.status !== 'STARTED') {
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); 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( await connection.execute(
`UPDATE qipai_cleaning_tasks `UPDATE qipai_cleaning_tasks
SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3), SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3),
photo_urls_json = ?, reject_reason = '' photo_urls_json = ?, reject_reason = ''
WHERE tenant_id = ? AND id = ?`, 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( await this.recordEventWithConnection(
connection, input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? '' connection, input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''
); );
@@ -854,14 +1067,106 @@ export class CleaningTaskRepository {
async assertCanUploadPhoto(input: CleaningActor & { taskId: string }) { async assertCanUploadPhoto(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
const [rows] = await this.pool.execute<CurrentStatusRow[]>( const [rows] = await this.pool.execute<CurrentTaskRuleRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId `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 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`, AND deleted_at IS NULL`,
[input.tenantId, input.taskId, input.userId] [input.tenantId, input.taskId, input.userId]
); );
if (!rows[0]) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); 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( async createForFinishedOrder(
@@ -877,23 +1182,45 @@ export class CleaningTaskRepository {
); );
const order = orders[0]; const order = orders[0];
if (!order) throw new CleaningTaskError('CLEANING_ORDER_NOT_FINISHED'); 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 taskNo = `CLN-${order.orderNo}`;
const [result] = await connection.execute<ResultSetHeader>( const [result] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_cleaning_tasks `INSERT IGNORE INTO qipai_cleaning_tasks
(tenant_id, store_id, room_id, order_id, task_no, status, priority, (tenant_id, store_id, room_id, order_id, task_no, status, priority,
reward_cents, requirement, photo_urls_json) reward_cents, requirement, cleaning_template_id, cleaning_template_version,
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, '订单结束后保洁', JSON_ARRAY())`, photo_required, min_photo_count, max_photo_count, exempt_policy, photo_urls_json)
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo] 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) { if (result.affectedRows === 1) {
await connection.execute( await connection.execute(
`INSERT IGNORE INTO qipai_cleaning_task_events `INSERT IGNORE INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata) (tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
SELECT tenant_id, id, NULL, 'WAITING', 'AUTO_CREATE', ?, ?, '订单结束自动创建保洁任务', SELECT tenant_id, id, NULL, 'WAITING', 'AUTO_CREATE', ?, ?, '订单结束自动创建保洁任务',
JSON_OBJECT('orderId', ?) JSON_OBJECT('orderId', ?, 'cleaningTemplateId', ?, 'cleaningTemplateVersion', ?)
FROM qipai_cleaning_tasks FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND order_id = ?`, 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.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.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.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.claimed_at AS claimedAt, t.started_at AS startedAt,
t.submitted_at AS submittedAt, t.completed_at AS completedAt, t.submitted_at AS submittedAt, t.completed_at AS completedAt,
(SELECT COUNT(*) FROM qipai_cleaning_task_members m (SELECT COUNT(*) FROM qipai_cleaning_task_members m
@@ -1175,7 +1509,7 @@ export class CleaningTaskRepository {
action: string, action: string,
timestampColumn: 'started_at' | 'submitted_at', timestampColumn: 'started_at' | 'submitted_at',
note: string, 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'); this.assertCleaner(input.access, 'write');
const extraAssignments: string[] = []; const extraAssignments: string[] = [];
@@ -1188,6 +1522,7 @@ export class CleaningTaskRepository {
extraAssignments.push('reject_reason = ?'); extraAssignments.push('reject_reason = ?');
extraParams.push(extra.reject_reason); extraParams.push(extra.reject_reason);
} }
if (extra?.incrementRework) extraAssignments.push('rework_count = rework_count + 1');
const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : ''; const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : '';
await this.transaction(async (connection) => { await this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>( 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) { private async assertStoreVisible(input: CleaningActor, taskId: string) {
if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return; if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return;
const [rows] = await this.pool.execute<RowDataPacket[]>( 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) { function storeScopeSql(access: AccessProfile, storeExpression: string) {
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) { if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
return '1 = 1'; return '1 = 1';
@@ -1441,8 +1837,16 @@ function publicTask(row: CleaningTaskRow) {
priority: Number(row.priority), priority: Number(row.priority),
rewardCents: Number(row.rewardCents), rewardCents: Number(row.rewardCents),
requirement: row.requirement, 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), photoUrls: parseJsonArray(row.photoUrlsJson),
rejectReason: row.rejectReason, rejectReason: row.rejectReason,
reworkCount: Number(row.reworkCount),
photoRevision: Number(row.photoRevision),
claimedAt: row.claimedAt, claimedAt: row.claimedAt,
startedAt: row.startedAt, startedAt: row.startedAt,
submittedAt: row.submittedAt, 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) { function publicTaskMember(row: CleaningTaskMemberRow) {
return { return {
id: String(row.id), 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) { function publicSettlement(row: SettlementRow) {
return { return {
id: String(row.id), id: String(row.id),
+31 -3
View File
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from 'node:crypto'; 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 { extname, resolve, sep } from 'node:path';
import sharp from 'sharp'; import sharp from 'sharp';
@@ -33,18 +33,33 @@ export class MediaStorage {
if (input.body.length === 0 || input.body.length > 8 * 1024 * 1024) { if (input.body.length === 0 || input.body.length > 8 * 1024 * 1024) {
throw new MediaValidationError('IMAGE_SIZE_INVALID'); 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'); 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'); throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
} }
if (declaredExtension !== declaredFormat) {
throw new MediaValidationError('IMAGE_TYPE_MISMATCH');
}
let result: Buffer; let result: Buffer;
let metadata: sharp.Metadata; let metadata: sharp.Metadata;
try { try {
const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 }); const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 });
metadata = await source.metadata(); metadata = await source.metadata();
if (!metadata.width || !metadata.height) throw new Error('missing dimensions'); if (!metadata.width || !metadata.height) throw new Error('missing dimensions');
if (metadata.format !== declaredFormat) throw new Error('declared image type mismatch');
result = await source result = await source
.rotate() .rotate()
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true }) .resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
@@ -74,4 +89,17 @@ export class MediaStorage {
checksumSha256: createHash('sha256').update(result).digest('hex') 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/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql', 'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', '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/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql', 'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.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: [ down: [
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.down.sql', 'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
'database/migrations/2026081003_m08d_franchise_leads.down.sql', 'database/migrations/2026081003_m08d_franchise_leads.down.sql',
'database/migrations/2026081002_m08d_content_asset_scope.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.event === 'ORDER_ROOM_CHANGED') {
if (payload.previousRoomId && payload.previousRoomId !== order.roomId) { 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); await this.startOrderDevices(order, payload.traceId);
return { orderId: order.id, action: 'ROOM_CHANGED' }; 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); await this.cancelRoomDevices(order, payload.traceId, order.roomId);
return { orderId: order.id, action: 'CANCELLED' }; 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) { private async loadOrder(tenantId: string, orderId: string) {
const [rows] = await this.pool.execute<OrderRow[]>( const [rows] = await this.pool.execute<OrderRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId, `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 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 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 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 settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
const settlementPayoutStateSchema = z.enum([ const settlementPayoutStateSchema = z.enum([
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM' '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), olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
limit: z.coerce.number().int().min(1).max(100).default(20) 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 { export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository, repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit' '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' | 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts' | 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats' | 'managerStatistics'>; | 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
| 'listTemplates' | 'upsertTemplate'
| 'stats' | 'managerStatistics'>;
mediaStorage?: MediaStorage; mediaStorage?: MediaStorage;
payoutService?: Pick<CleaningPayoutService, payoutService?: Pick<CleaningPayoutService,
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer' '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) => { app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read'); const actor = await requireActor(request, reply, options, 'read');
if (!actor) return; 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) => { app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write'); const actor = await requireActor(request, reply, options, 'write');
if (!actor) return; if (!actor) return;
@@ -366,14 +425,25 @@ export async function registerCleaningRoutes(
return invalid(reply, request.traceId); return invalid(reply, request.traceId);
} }
return handle(reply, request.traceId, async () => { 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({ const image = await options.mediaStorage!.storeImage({
tenantId: actor.tenantId, tenantId: actor.tenantId,
storeId: context.storeId,
originalName, originalName,
contentType: singleHeader(request.headers['x-image-content-type']) ?? '', contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
body: request.body as Buffer 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) => { app.post('/admin-api/cleaning/settlements', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write'); const actor = await requireActor(request, reply, options, 'write');
if (!actor) return; if (!actor) return;
@@ -679,8 +784,7 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
&& !(error instanceof CleaningPayoutError) && !(error instanceof CleaningPayoutError)
&& !(error instanceof WechatPayError)) throw error; && !(error instanceof WechatPayError)) throw error;
const code = error.code; const code = error.code;
const statusCode = code === 'CLEANING_TASK_FORBIDDEN' const statusCode = code.endsWith('_FORBIDDEN') ? 403 : 409;
|| code === 'CLEANING_SETTLEMENT_FORBIDDEN' ? 403 : 409;
return reply.status(statusCode).send({ return reply.status(statusCode).send({
code, code,
message: 'The cleaning task request cannot be completed.', message: 'The cleaning task request cannot be completed.',
+91 -5
View File
@@ -77,6 +77,25 @@ const app = await buildApp({
}, },
async assertCanUploadPhoto(input) { async assertCanUploadPhoto(input) {
calls.push(['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) { async submit(input) {
calls.push(['submit', input]); calls.push(['submit', input]);
@@ -116,6 +135,15 @@ const app = await buildApp({
createdAt: new Date() 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) { async addMember(input) {
calls.push(['addMember', input]); calls.push(['addMember', input]);
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)]; return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
@@ -171,6 +199,14 @@ const app = await buildApp({
calls.push(['reclaimTimeouts', input]); calls.push(['reclaimTimeouts', input]);
return { reclaimed: 2, taskIds: ['101', '102'] }; 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) { async stats(input) {
calls.push(['stats', input]); calls.push(['stats', input]);
return { return {
@@ -281,19 +317,44 @@ const app = await buildApp({
async storeImage(input) { async storeImage(input) {
calls.push(['storeImage', input]); calls.push(['storeImage', input]);
return { return {
storagePath: 'tenants/7/shared/cleaning.webp', storagePath: 'tenants/7/stores/11/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp', publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp',
mimeType: 'image/webp', mimeType: 'image/webp',
byteSize: input.body.length, byteSize: input.body.length,
width: 640, width: 640,
height: 480, height: 480,
checksumSha256: 'abc' 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({ const hall = await app.inject({
method: 'GET', method: 'GET',
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10', url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
@@ -396,9 +457,11 @@ const photo = await app.inject({
payload: Buffer.from('fake-image') payload: Buffer.from('fake-image')
}); });
assert.equal(photo.statusCode, 201); assert.equal(photo.statusCode, 201);
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp'); assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp');
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto'); assert.equal(calls.at(-3)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-1)[0], 'storeImage'); 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({ const submit = await app.inject({
method: 'POST', method: 'POST',
@@ -498,6 +561,16 @@ assert.equal(calls.at(-1)[0], 'listEvents');
assert.equal(calls.at(-1)[1].taskId, '101'); assert.equal(calls.at(-1)[1].taskId, '101');
assert.equal(events.json().data[0].action, 'SUBMIT'); 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({ const addedMember = await app.inject({
method: 'POST', method: 'POST',
url: '/admin-api/cleaning/tasks/101/members', 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)[0], 'reclaimTimeouts');
assert.equal(calls.at(-1)[1].olderThanMinutes, 30); 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({ const stats = await app.inject({
method: 'GET', method: 'GET',
url: '/app-api/cleaning/stats', url: '/app-api/cleaning/stats',
+18 -1
View File
@@ -21,7 +21,18 @@ try {
assert.equal(image.mimeType, 'image/webp'); assert.equal(image.mimeType, 'image/webp');
assert.ok(image.width <= 1920); assert.ok(image.width <= 1920);
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/); 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( await assert.rejects(
() => storage.storeImage({ () => storage.storeImage({
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain', tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
@@ -29,6 +40,12 @@ try {
}), }),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID' (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 { } finally {
await rm(root, { recursive: true, force: true }); 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 adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.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 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 = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -489,7 +492,22 @@ assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
assert.match(adminAuthUpSql, /'2026081004'/); assert.match(adminAuthUpSql, /'2026081004'/);
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/); assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/); 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(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/); 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, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); 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[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/); assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/); 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 calls = [];
const fakePool = { const fakePool = {
+150 -11
View File
@@ -60,6 +60,9 @@ const expectedTables = [
'qipai_async_tasks', 'qipai_async_tasks',
'qipai_audit_logs', 'qipai_audit_logs',
'qipai_auth_sessions', 'qipai_auth_sessions',
'qipai_cleaning_task_photos',
'qipai_cleaning_task_submissions',
'qipai_cleaning_templates',
'qipai_collection_accounts', 'qipai_collection_accounts',
'qipai_device_alerts', 'qipai_device_alerts',
'qipai_device_channels', 'qipai_device_channels',
@@ -139,13 +142,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', ['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014', '2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219', '2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004'] '2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
); );
return rows; return rows;
} }
@@ -1833,7 +1836,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible'); assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId); const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, 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); assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, { await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone 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] [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 rollbackTaskId = await insertTask();
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`; const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
@@ -2650,9 +2666,10 @@ async function assertCleaningTaskTransactions(pool, context) {
await repository.start({ await repository.start({
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId ...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
}); });
const firstPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-first');
await repository.submit({ await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId, ...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({ await repository.reject({
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework' ...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
@@ -2666,9 +2683,10 @@ async function assertCleaningTaskTransactions(pool, context) {
await repository.rework({ await repository.rework({
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId ...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
}); });
const secondPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-second');
await repository.submit({ await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId, ...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 = { const completeInput = {
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted' ...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
@@ -2688,6 +2706,74 @@ async function assertCleaningTaskTransactions(pool, context) {
status: lifecycleRows[0].status, status: lifecycleRows[0].status,
completedSet: Number(lifecycleRows[0].completedSet) completedSet: Number(lifecycleRows[0].completedSet)
}, { status: 'COMPLETED', completedSet: 1 }); }, { 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( const [lifecycleEventRows] = await pool.query(
`SELECT action FROM qipai_cleaning_task_events `SELECT action FROM qipai_cleaning_task_events
WHERE tenant_id = ? AND task_id = ? ORDER BY id`, 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(settledMemberRows[0].removedAt, null);
assert.equal(Number(settledMemberRows[0].settled), 1); 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( const [orderResult] = await pool.query(
`INSERT INTO qipai_orders `INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at, (tenant_id, store_id, room_id, order_no, status, start_at, end_at,
@@ -2812,7 +2918,10 @@ async function assertCleaningTaskTransactions(pool, context) {
orderConnection.release(); orderConnection.release();
} }
const [generatedRows] = await pool.query( 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 (SELECT COUNT(*) FROM qipai_cleaning_task_events e
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id
AND e.action = 'AUTO_CREATE') AS eventCount AND e.action = 'AUTO_CREATE') AS eventCount
@@ -2822,10 +2931,34 @@ async function assertCleaningTaskTransactions(pool, context) {
); );
assert.equal(generatedRows.length, 1); assert.equal(generatedRows.length, 1);
assert.equal(generatedRows[0].status, 'WAITING'); 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); 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( 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: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' }, { version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' }, { 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); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2904,7 +3038,7 @@ try {
await executeMigrationPlan(pool, plans.down); await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []); assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(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.up);
await executeMigrationPlan(pool, plans.verify); await executeMigrationPlan(pool, plans.verify);
@@ -2932,7 +3066,8 @@ try {
{ version: '2026062220', name: 'm06c_iot_messages' }, { version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' }, { version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' }, { 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); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -3069,7 +3204,11 @@ try {
'idempotent cleaning completion trace', 'idempotent cleaning completion trace',
'SKIP LOCKED cleaning timeout reclaim batch', 'SKIP LOCKED cleaning timeout reclaim batch',
'settled cleaning member preservation', '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)); }, null, 2));
} finally { } finally {
@@ -6,6 +6,7 @@ import {
import { DeviceControlError } from '../dist/devices/device-control-service.js'; import { DeviceControlError } from '../dist/devices/device-control-service.js';
const calls = []; const calls = [];
let activeRoomOrder = false;
let currentOrder = { let currentOrder = {
id: 31, id: 31,
tenantId: 7, tenantId: 7,
@@ -17,6 +18,11 @@ let currentOrder = {
}; };
const service = new OrderDeviceAutomationService({ const service = new OrderDeviceAutomationService({
async execute(sql, params) { 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')) { if (sql.includes('FROM qipai_orders')) {
assert.equal(params[0], '7'); assert.equal(params[0], '7');
assert.equal(params[1], '31'); assert.equal(params[1], '31');
@@ -91,6 +97,17 @@ await service.handleTask(task({
assert.equal(calls.at(-2)[0], 'cancelTask'); assert.equal(calls.at(-2)[0], 'cancelTask');
assert.equal(calls.at(-1)[2].on, false); 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' }; currentOrder = { ...currentOrder, roomId: 52, status: 'IN_PROGRESS' };
const changed = await service.handleTask(task({ const changed = await service.handleTask(task({
tenantId: '7', 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 const taskId = event.currentTarget.dataset.taskId
if (!taskId) return if (!taskId) return
try { try {
const result = await wxChooseMedia(9)
const current = this.data.selectedPhotosByTask[taskId] || [] 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 }) this.setData({ [`selectedPhotosByTask.${taskId}`]: next })
} catch (error) { } catch (error) {
this.setData({ errorMessage: error.message || '选择照片失败' }) this.setData({ errorMessage: error.message || '选择照片失败' })
@@ -74,8 +79,10 @@ Page({
async submitTask(event) { async submitTask(event) {
const taskId = event.currentTarget.dataset.taskId const taskId = event.currentTarget.dataset.taskId
const localPhotos = this.data.selectedPhotosByTask[taskId] || [] const localPhotos = this.data.selectedPhotosByTask[taskId] || []
if (localPhotos.length === 0) { const task = this.data.myTasks.find((item) => item.id === taskId)
this.setData({ errorMessage: '请先上传至少一张保洁照片' }) const minPhotos = Number(task?.minPhotoCount ?? 1)
if (localPhotos.length < minPhotos) {
this.setData({ errorMessage: `请先选择至少 ${minPhotos} 张保洁照片` })
return return
} }
this.setData({ loading: true, errorMessage: '' }) this.setData({ loading: true, errorMessage: '' })
@@ -125,7 +132,10 @@ function formatTask(task) {
canStart: task.status === 'CLAIMED', canStart: task.status === 'CLAIMED',
canSubmit: task.status === 'STARTED', canSubmit: task.status === 'STARTED',
canRework: task.status === 'REJECTED', 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.canStart}}" data-task-id="{{item.id}}" bindtap="startTask">开始</button>
<button wx:if="{{item.canRework}}" data-task-id="{{item.id}}" bindtap="reworkTask">重做</button> <button wx:if="{{item.canRework}}" data-task-id="{{item.id}}" bindtap="reworkTask">重做</button>
<view wx:if="{{item.canUpload}}" class="submit-box"> <view wx:if="{{item.canUpload}}" class="submit-box">
<button data-task-id="{{item.id}}" bindtap="choosePhotos">选择照片</button> <button data-task-id="{{item.id}}" data-max-photos="{{item.maxPhotoCount}}" bindtap="choosePhotos">选择照片</button>
<view class="muted">至少 1 张,最多 9 张</view> <view class="muted">{{item.photoRuleText}}</view>
<button data-task-id="{{item.id}}" bindtap="submitTask">提交验收</button> <button data-task-id="{{item.id}}" bindtap="submitTask">提交验收</button>
</view> </view>
</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" Assert-NativeSuccess "check-admin-m08-d-r1 source"
node scripts/check-miniapp-m08-c.mjs node scripts/check-miniapp-m08-c.mjs
Assert-NativeSuccess "check-miniapp-m08-c" 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") { if (Test-Path "admin/package.json") {
Push-Location admin Push-Location admin