feat(M09-B): 完成清洁规则与照片验收闭环
This commit is contained in:
@@ -9,8 +9,12 @@ import type {
|
||||
CleaningSettlementDetail,
|
||||
CleaningStatistics,
|
||||
CleaningTask,
|
||||
CleaningTemplate,
|
||||
CleaningTemplateScope,
|
||||
CleaningExemptPolicy,
|
||||
CleaningTaskEvent,
|
||||
CleaningTaskMember,
|
||||
CleaningTaskSubmission,
|
||||
DecorationComponent,
|
||||
DecorationVersion,
|
||||
FranchiseApplication,
|
||||
@@ -449,6 +453,29 @@ export function getCleaningStatistics(
|
||||
return request<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export function listCleaningTemplates(session: ApiSession) {
|
||||
return request<CleaningTemplate[]>(session, '/cleaning/templates');
|
||||
}
|
||||
|
||||
export function upsertCleaningTemplate(
|
||||
session: ApiSession,
|
||||
input: {
|
||||
scopeType: CleaningTemplateScope;
|
||||
scopeId?: string;
|
||||
name: string;
|
||||
requirement: string;
|
||||
photoRequired: boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
}
|
||||
) {
|
||||
return request<CleaningTemplate>(session, '/cleaning/templates', {
|
||||
method: 'PUT', body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listStaffUsers(
|
||||
session: ApiSession,
|
||||
input: {
|
||||
@@ -729,6 +756,13 @@ export function listCleaningTaskEvents(session: ApiSession, taskId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function listCleaningTaskSubmissions(session: ApiSession, taskId: string) {
|
||||
return request<CleaningTaskSubmission[]>(
|
||||
session,
|
||||
`/cleaning/tasks/${encodeURIComponent(taskId)}/submissions`
|
||||
);
|
||||
}
|
||||
|
||||
export function addCleaningTaskMember(
|
||||
session: ApiSession,
|
||||
taskId: string,
|
||||
|
||||
@@ -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>
|
||||
@@ -149,7 +149,7 @@
|
||||
size="small"
|
||||
type="warning"
|
||||
:icon="Ban"
|
||||
:disabled="!canExempt(row.status)"
|
||||
:disabled="!canExempt(row)"
|
||||
@click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })"
|
||||
>
|
||||
{{ '\u514d\u6e05\u6d01' }}
|
||||
@@ -323,7 +323,7 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="760px">
|
||||
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="880px">
|
||||
<div v-if="detailDialog.task" class="dialog-stack">
|
||||
<div class="detail-grid">
|
||||
<span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span>
|
||||
@@ -341,6 +341,12 @@
|
||||
<el-descriptions-item label="要求" :span="2">
|
||||
{{ compactText(detailDialog.task.requirement) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="照片规则">
|
||||
{{ detailDialog.task.photoRequired ? `${detailDialog.task.minPhotoCount}–${detailDialog.task.maxPhotoCount} 张` : `可选,最多 ${detailDialog.task.maxPhotoCount} 张` }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务快照">
|
||||
模板 v{{ detailDialog.task.cleaningTemplateVersion }} · 照片修订 {{ detailDialog.task.photoRevision }} · 补做 {{ detailDialog.task.reworkCount }} 次
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="驳回原因" :span="2">
|
||||
{{ compactText(detailDialog.task.rejectReason) }}
|
||||
</el-descriptions-item>
|
||||
@@ -351,6 +357,46 @@
|
||||
<span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span>
|
||||
<span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span>
|
||||
</div>
|
||||
<section class="stat-block" v-loading="detailDialog.loadingSubmissions">
|
||||
<header>
|
||||
<h3>验收照片版本</h3>
|
||||
<span>{{ detailDialog.submissions.length }} 次提交</span>
|
||||
</header>
|
||||
<div v-if="detailDialog.submissions.length" class="submission-list">
|
||||
<article
|
||||
v-for="submission in detailDialog.submissions"
|
||||
:key="submission.id"
|
||||
class="submission-card"
|
||||
>
|
||||
<header>
|
||||
<strong>第 {{ submission.revision }} 版</strong>
|
||||
<el-tag
|
||||
:type="submission.status === 'ACCEPTED' ? 'success' : submission.status === 'REJECTED' ? 'danger' : 'warning'"
|
||||
effect="light"
|
||||
>
|
||||
{{ submission.status }}
|
||||
</el-tag>
|
||||
<span>{{ shortDate(submission.submittedAt) }}</span>
|
||||
</header>
|
||||
<p v-if="submission.rejectReason" class="submission-reason">
|
||||
驳回原因:{{ submission.rejectReason }}
|
||||
</p>
|
||||
<p v-if="submission.note" class="submission-note">提交备注:{{ submission.note }}</p>
|
||||
<div v-if="submission.photoUrls.length" class="photo-grid">
|
||||
<el-image
|
||||
v-for="url in submission.photoUrls"
|
||||
:key="`${submission.id}-${url}`"
|
||||
:src="url"
|
||||
fit="cover"
|
||||
:preview-src-list="submission.photoUrls"
|
||||
preview-teleported
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-else description="本版无照片" :image-size="48" />
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else description="暂无提交版本" :image-size="64" />
|
||||
</section>
|
||||
<section class="stat-block">
|
||||
<header>
|
||||
<h3>操作流水</h3>
|
||||
@@ -451,6 +497,7 @@ import {
|
||||
addCleaningTaskMember,
|
||||
listCleaningTaskEvents,
|
||||
listCleaningTaskMembers,
|
||||
listCleaningTaskSubmissions,
|
||||
removeCleaningTaskMember
|
||||
} from '../api';
|
||||
import { compactText, money, shortDate } from '../format';
|
||||
@@ -458,6 +505,7 @@ import type {
|
||||
CleaningTask,
|
||||
CleaningTaskEvent,
|
||||
CleaningTaskMember,
|
||||
CleaningTaskSubmission,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
TaskStatus
|
||||
@@ -510,16 +558,20 @@ const detailDialog = reactive<{
|
||||
open: boolean;
|
||||
loadingEvents: boolean;
|
||||
loadingMembers: boolean;
|
||||
loadingSubmissions: boolean;
|
||||
task: CleaningTask | null;
|
||||
events: CleaningTaskEvent[];
|
||||
members: CleaningTaskMember[];
|
||||
submissions: CleaningTaskSubmission[];
|
||||
}>({
|
||||
open: false,
|
||||
loadingEvents: false,
|
||||
loadingMembers: false,
|
||||
loadingSubmissions: false,
|
||||
task: null,
|
||||
events: [],
|
||||
members: []
|
||||
members: [],
|
||||
submissions: []
|
||||
});
|
||||
const memberDialog = reactive({
|
||||
open: false,
|
||||
@@ -536,7 +588,7 @@ const selectedSubmittedTasks = computed(() => selectedTasks.value
|
||||
.filter((task) => task.status === 'SUBMITTED'));
|
||||
const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length);
|
||||
const selectedExemptableTasks = computed(() => selectedTasks.value
|
||||
.filter((task) => canExempt(task.status)));
|
||||
.filter((task) => canExempt(task)));
|
||||
const selectedExemptableCount = computed(() => selectedExemptableTasks.value.length);
|
||||
|
||||
watch(
|
||||
@@ -553,11 +605,13 @@ function taskTag(status: TaskStatus) {
|
||||
}
|
||||
|
||||
function canSelectTask(row: CleaningTask) {
|
||||
return row.status === 'SUBMITTED' || canExempt(row.status);
|
||||
return row.status === 'SUBMITTED' || canExempt(row);
|
||||
}
|
||||
|
||||
function canExempt(status: TaskStatus) {
|
||||
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(status);
|
||||
function canExempt(task: CleaningTask) {
|
||||
if (task.exemptPolicy === 'DISABLED') return false;
|
||||
if (task.exemptPolicy === 'BEFORE_START') return ['WAITING', 'CLAIMED'].includes(task.status);
|
||||
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(task.status);
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: CleaningTask[]) {
|
||||
@@ -583,20 +637,25 @@ async function openDetail(row: CleaningTask) {
|
||||
detailDialog.task = row;
|
||||
detailDialog.events = [];
|
||||
detailDialog.members = [];
|
||||
detailDialog.submissions = [];
|
||||
detailDialog.loadingEvents = true;
|
||||
detailDialog.loadingMembers = true;
|
||||
detailDialog.loadingSubmissions = true;
|
||||
try {
|
||||
const [events, members] = await Promise.all([
|
||||
const [events, members, submissions] = await Promise.all([
|
||||
listCleaningTaskEvents(props.session, row.id),
|
||||
listCleaningTaskMembers(props.session, row.id)
|
||||
listCleaningTaskMembers(props.session, row.id),
|
||||
listCleaningTaskSubmissions(props.session, row.id)
|
||||
]);
|
||||
detailDialog.events = events;
|
||||
detailDialog.members = members;
|
||||
detailDialog.submissions = submissions;
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败');
|
||||
} finally {
|
||||
detailDialog.loadingEvents = false;
|
||||
detailDialog.loadingMembers = false;
|
||||
detailDialog.loadingSubmissions = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,11 +838,18 @@ function exportTaskDetail() {
|
||||
`${event.action}:${shortDate(event.createdAt)}`,
|
||||
`${compactText(event.fromStatus)} -> ${event.toStatus} ${compactText(event.actorId)} ${compactText(event.note)}`
|
||||
]),
|
||||
...task.photoUrls.map((url, index) => [
|
||||
...detailDialog.submissions.flatMap((submission) => [
|
||||
[
|
||||
'submission',
|
||||
`revision_${submission.revision}`,
|
||||
`${submission.status} ${submission.rejectReason || submission.note || ''}`
|
||||
],
|
||||
...submission.photoUrls.map((url, index) => [
|
||||
'photo',
|
||||
`photo_${index + 1}`,
|
||||
`revision_${submission.revision}_photo_${index + 1}`,
|
||||
url
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,9 @@
|
||||
@reset-sessions="handleResetCleanerSessions"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="清洁规则" name="rules">
|
||||
<CleaningRulesPanel :session="session" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="联调" name="handoff">
|
||||
<CleaningFieldHandoffPanel />
|
||||
</el-tab-pane>
|
||||
@@ -159,6 +162,7 @@ import CleaningTasksPanel from './CleaningTasksPanel.vue';
|
||||
import CleaningSettlementsPanel from './CleaningSettlementsPanel.vue';
|
||||
import CleaningStatisticsPanel from './CleaningStatisticsPanel.vue';
|
||||
import CleanersPanel from './CleanersPanel.vue';
|
||||
import CleaningRulesPanel from './CleaningRulesPanel.vue';
|
||||
import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
|
||||
import {
|
||||
ApiError,
|
||||
|
||||
@@ -1291,6 +1291,43 @@ textarea {
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.submission-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.submission-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.submission-card > header {
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.submission-card > header span:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.submission-reason,
|
||||
.submission-note {
|
||||
margin: 0;
|
||||
color: #60708a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.submission-reason {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.trend-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@@ -614,8 +614,16 @@ export interface CleaningTask {
|
||||
priority: number;
|
||||
rewardCents: number;
|
||||
requirement: string;
|
||||
cleaningTemplateId: string | null;
|
||||
cleaningTemplateVersion: number;
|
||||
photoRequired: boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
photoUrls: string[];
|
||||
rejectReason: string;
|
||||
reworkCount: number;
|
||||
photoRevision: number;
|
||||
claimedAt?: string;
|
||||
startedAt?: string;
|
||||
submittedAt?: string;
|
||||
@@ -625,6 +633,27 @@ export interface CleaningTask {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM';
|
||||
export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE';
|
||||
|
||||
export interface CleaningTemplate {
|
||||
id: string;
|
||||
scopeKey: string;
|
||||
scopeType: CleaningTemplateScope;
|
||||
storeId: string | null;
|
||||
roomId: string | null;
|
||||
name: string;
|
||||
requirement: string;
|
||||
photoRequired: boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
version: number;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
updatedAt: string;
|
||||
appliesToExistingTasks: false;
|
||||
}
|
||||
|
||||
export interface CleaningTaskMember {
|
||||
id: string;
|
||||
taskId: string;
|
||||
@@ -649,6 +678,20 @@ export interface CleaningTaskEvent {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CleaningTaskSubmission {
|
||||
id: string;
|
||||
taskId: string;
|
||||
revision: number;
|
||||
status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED';
|
||||
photoUrls: string[];
|
||||
note: string;
|
||||
rejectReason: string;
|
||||
submittedBy: string;
|
||||
reviewedBy: string | null;
|
||||
submittedAt: string;
|
||||
reviewedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CleaningSettlement {
|
||||
id: string;
|
||||
settlementNo: string;
|
||||
|
||||
@@ -9,6 +9,8 @@ export const cleaningTaskStatuses = [
|
||||
export type CleaningTaskStatus = typeof cleaningTaskStatuses[number];
|
||||
export type CleaningSettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||
export type CleaningSettlementPayoutState = 'NONE' | 'SUCCESS' | 'FAIL' | 'PROCESSING' | 'WAIT_USER_CONFIRM';
|
||||
export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM';
|
||||
export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE';
|
||||
|
||||
export class CleaningTaskError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
@@ -36,8 +38,16 @@ interface CleaningTaskRow extends RowDataPacket {
|
||||
priority: number;
|
||||
rewardCents: number;
|
||||
requirement: string;
|
||||
cleaningTemplateId: string | null;
|
||||
cleaningTemplateVersion: number;
|
||||
photoRequired: number | boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
photoUrlsJson: string | string[] | null;
|
||||
rejectReason: string;
|
||||
reworkCount: number;
|
||||
photoRevision: number;
|
||||
claimedAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
submittedAt: Date | null;
|
||||
@@ -68,10 +78,55 @@ interface CleaningTaskEventRow extends RowDataPacket {
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
interface CleaningTaskSubmissionRow extends RowDataPacket {
|
||||
id: string;
|
||||
taskId: string;
|
||||
revision: number;
|
||||
status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED';
|
||||
photoUrlsJson: string | string[];
|
||||
note: string;
|
||||
rejectReason: string;
|
||||
submittedBy: string;
|
||||
reviewedBy: string | null;
|
||||
submittedAt: Date;
|
||||
reviewedAt: Date | null;
|
||||
}
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
|
||||
interface AmountRow extends RowDataPacket { amount: number | null }
|
||||
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null }
|
||||
interface CurrentTaskRuleRow extends CurrentStatusRow {
|
||||
storeId: string;
|
||||
photoRequired: number | boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
reworkCount: number;
|
||||
}
|
||||
interface CleaningTemplateRow extends RowDataPacket {
|
||||
id: string;
|
||||
scopeKey: string;
|
||||
scopeType: CleaningTemplateScope;
|
||||
storeId: string | null;
|
||||
roomId: string | null;
|
||||
name: string;
|
||||
requirement: string;
|
||||
photoRequired: number | boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
version: number;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
updatedAt: Date;
|
||||
}
|
||||
interface PhotoOwnershipRow extends RowDataPacket {
|
||||
id: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
interface ExpiredPhotoRow extends RowDataPacket {
|
||||
id: string;
|
||||
storagePath: string;
|
||||
}
|
||||
interface ReclaimRow extends CurrentStatusRow { id: string }
|
||||
interface FinishedOrderRow extends RowDataPacket {
|
||||
id: string;
|
||||
@@ -167,6 +222,72 @@ interface ManagerDailyTrendRow extends RowDataPacket {
|
||||
export class CleaningTaskRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async listTemplates(input: CleaningActor) {
|
||||
this.assertCleaner(input.access, 'read');
|
||||
const [rows] = await this.pool.execute<CleaningTemplateRow[]>(
|
||||
`SELECT t.id, t.scope_key AS scopeKey, t.scope_type AS scopeType,
|
||||
t.store_id AS storeId, t.room_id AS roomId, t.name, t.requirement,
|
||||
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
|
||||
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
|
||||
t.version, t.status, t.updated_at AS updatedAt
|
||||
FROM qipai_cleaning_templates t
|
||||
WHERE t.tenant_id = ?
|
||||
AND (t.scope_type = 'TENANT' OR ${storeScopeSql(input.access, 't.store_id')})
|
||||
ORDER BY FIELD(t.scope_type, 'TENANT', 'STORE', 'ROOM'), t.scope_key`,
|
||||
[input.tenantId]
|
||||
);
|
||||
return rows.map(publicTemplate);
|
||||
}
|
||||
|
||||
async upsertTemplate(input: CleaningActor & {
|
||||
scopeType: CleaningTemplateScope;
|
||||
scopeId?: string;
|
||||
name: string;
|
||||
requirement: string;
|
||||
photoRequired: boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
}) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
if (input.maxPhotoCount < 1 || input.maxPhotoCount > 9
|
||||
|| input.minPhotoCount < 0 || input.minPhotoCount > input.maxPhotoCount
|
||||
|| (input.photoRequired && input.minPhotoCount < 1)
|
||||
|| (!input.photoRequired && input.minPhotoCount !== 0)) {
|
||||
throw new CleaningTaskError('CLEANING_TEMPLATE_PHOTO_RULE_INVALID');
|
||||
}
|
||||
const scope = await this.resolveTemplateScope(input, input.scopeType, input.scopeId);
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_cleaning_templates
|
||||
(tenant_id, scope_key, scope_type, store_id, room_id, name, requirement,
|
||||
photo_required, min_photo_count, max_photo_count, exempt_policy, status,
|
||||
created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
id = LAST_INSERT_ID(id), name = VALUES(name), requirement = VALUES(requirement),
|
||||
photo_required = VALUES(photo_required), min_photo_count = VALUES(min_photo_count),
|
||||
max_photo_count = VALUES(max_photo_count), exempt_policy = VALUES(exempt_policy),
|
||||
status = VALUES(status), updated_by = VALUES(updated_by), version = version + 1`,
|
||||
[input.tenantId, scope.scopeKey, input.scopeType, scope.storeId, scope.roomId,
|
||||
input.name.slice(0, 128), input.requirement.slice(0, 512), input.photoRequired ? 1 : 0,
|
||||
input.minPhotoCount, input.maxPhotoCount, input.exemptPolicy, input.status,
|
||||
input.userId, input.userId]
|
||||
);
|
||||
const templateId = String(result.insertId);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id, trace_id, metadata)
|
||||
VALUES (?, 'USER', ?, 'CLEANING_TEMPLATE_UPSERT', 'CLEANING_TEMPLATE', ?, ?,
|
||||
JSON_OBJECT('scopeType', ?, 'scopeKey', ?, 'appliesToExistingTasks', FALSE))`,
|
||||
[input.tenantId, input.userId, templateId, input.traceId,
|
||||
input.scopeType, scope.scopeKey]
|
||||
);
|
||||
return this.getTemplate(connection, input.tenantId, templateId);
|
||||
});
|
||||
}
|
||||
|
||||
async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
|
||||
this.assertCleaner(input.access, 'read');
|
||||
const status = input.status ?? 'WAITING';
|
||||
@@ -263,10 +384,9 @@ export class CleaningTaskRepository {
|
||||
}
|
||||
|
||||
async rework(input: CleaningActor & { taskId: string }) {
|
||||
return this.moveMine(input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', {
|
||||
photo_urls_json: JSON.stringify([]),
|
||||
reject_reason: ''
|
||||
});
|
||||
return this.moveMine(
|
||||
input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', { incrementRework: true }
|
||||
);
|
||||
}
|
||||
|
||||
async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
|
||||
@@ -289,7 +409,8 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
|
||||
SET rework_count = rework_count + IF(status = 'REJECTED', 1, 0),
|
||||
status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
|
||||
started_at = NULL, submitted_at = NULL, completed_at = NULL, settled_at = NULL,
|
||||
cancelled_at = NULL, photo_urls_json = JSON_ARRAY(), reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
@@ -304,9 +425,28 @@ export class CleaningTaskRepository {
|
||||
}
|
||||
|
||||
async complete(input: CleaningActor & { taskId: string; note?: string }) {
|
||||
return this.moveManaged(
|
||||
input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''
|
||||
this.assertCleaner(input.access, 'write');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
await this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'COMPLETED', completed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_submissions
|
||||
SET status = 'ACCEPTED', reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND task_id = ? AND status = 'SUBMITTED'`,
|
||||
[input.userId, input.tenantId, input.taskId]
|
||||
);
|
||||
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, 'SUBMITTED', 'COMPLETED', 'COMPLETE', input.note ?? ''
|
||||
);
|
||||
});
|
||||
return this.getTask(input, input.taskId);
|
||||
}
|
||||
|
||||
async reject(input: CleaningActor & { taskId: string; reason: string }) {
|
||||
@@ -320,6 +460,12 @@ export class CleaningTaskRepository {
|
||||
[input.reason.slice(0, 512), input.tenantId, input.taskId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_submissions
|
||||
SET status = 'REJECTED', reject_reason = ?, reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND task_id = ? AND status = 'SUBMITTED'`,
|
||||
[input.reason.slice(0, 512), input.userId, input.tenantId, input.taskId]
|
||||
);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason
|
||||
);
|
||||
@@ -331,8 +477,11 @@ export class CleaningTaskRepository {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
await this.transaction(async (connection) => {
|
||||
const [currentRows] = await connection.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
const [currentRows] = await connection.execute<CurrentTaskRuleRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
rework_count AS reworkCount
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
@@ -340,6 +489,12 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
const current = currentRows[0];
|
||||
if (!current) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
|
||||
const allowedStatuses = current.exemptPolicy === 'ANY_ACTIVE'
|
||||
? ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED']
|
||||
: current.exemptPolicy === 'BEFORE_START' ? ['WAITING', 'CLAIMED'] : [];
|
||||
if (!allowedStatuses.includes(current.status)) {
|
||||
throw new CleaningTaskError('CLEANING_EXEMPT_POLICY_DENIED');
|
||||
}
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'EXEMPT',
|
||||
@@ -348,10 +503,8 @@ export class CleaningTaskRepository {
|
||||
reject_reason = '',
|
||||
completed_at = NULL,
|
||||
settled_at = NULL
|
||||
WHERE tenant_id = ? AND id = ?
|
||||
AND status IN ('WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED')
|
||||
AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId]
|
||||
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId, current.status]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await connection.execute(
|
||||
@@ -395,6 +548,24 @@ export class CleaningTaskRepository {
|
||||
return rows.map(publicTaskEvent);
|
||||
}
|
||||
|
||||
async listSubmissions(input: CleaningActor & { taskId: string }) {
|
||||
this.assertCleaner(input.access, 'read');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
const [rows] = await this.pool.execute<CleaningTaskSubmissionRow[]>(
|
||||
`SELECT s.id, s.task_id AS taskId, s.revision, s.status,
|
||||
s.photo_urls_json AS photoUrlsJson, s.note,
|
||||
s.reject_reason AS rejectReason, s.submitted_by AS submittedBy,
|
||||
s.reviewed_by AS reviewedBy, s.submitted_at AS submittedAt,
|
||||
s.reviewed_at AS reviewedAt
|
||||
FROM qipai_cleaning_task_submissions s
|
||||
WHERE s.tenant_id = ? AND s.task_id = ?
|
||||
ORDER BY s.revision DESC, s.id DESC
|
||||
LIMIT 20`,
|
||||
[input.tenantId, input.taskId]
|
||||
);
|
||||
return rows.map(publicTaskSubmission);
|
||||
}
|
||||
|
||||
async addMember(input: CleaningActor & {
|
||||
taskId: string; cleanerUserId: string; rewardCents: number; note?: string;
|
||||
}) {
|
||||
@@ -825,26 +996,68 @@ export class CleaningTaskRepository {
|
||||
|
||||
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED');
|
||||
await this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
rework_count AS reworkCount
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
const current = rows[0];
|
||||
if (!current || !['STARTED', 'REJECTED'].includes(current.status)) {
|
||||
if (!current || current.status !== 'STARTED') {
|
||||
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
}
|
||||
const photoUrls = [...new Set(input.photoUrls)];
|
||||
if (photoUrls.length !== input.photoUrls.length
|
||||
|| photoUrls.length < Number(current.minPhotoCount)
|
||||
|| photoUrls.length > Number(current.maxPhotoCount)
|
||||
|| (current.photoRequired && photoUrls.length === 0)) {
|
||||
throw new CleaningTaskError('CLEANING_PHOTO_COUNT_INVALID');
|
||||
}
|
||||
let ownedPhotos: PhotoOwnershipRow[] = [];
|
||||
if (photoUrls.length > 0) {
|
||||
const placeholders = photoUrls.map(() => '?').join(',');
|
||||
const [photoRows] = await connection.execute<PhotoOwnershipRow[]>(
|
||||
`SELECT id, public_url AS publicUrl
|
||||
FROM qipai_cleaning_task_photos
|
||||
WHERE tenant_id = ? AND task_id = ? AND uploaded_by = ?
|
||||
AND status = 'PENDING' AND deleted_at IS NULL
|
||||
AND public_url IN (${placeholders})
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId, input.userId, ...photoUrls]
|
||||
);
|
||||
ownedPhotos = photoRows;
|
||||
if (ownedPhotos.length !== photoUrls.length) {
|
||||
throw new CleaningTaskError('CLEANING_PHOTO_OWNERSHIP_INVALID');
|
||||
}
|
||||
}
|
||||
const revision = Number(current.reworkCount) + 1;
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3),
|
||||
photo_urls_json = ?, reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId]
|
||||
[JSON.stringify(photoUrls), input.tenantId, input.taskId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_cleaning_task_submissions
|
||||
(tenant_id, task_id, revision, status, photo_urls_json, note, submitted_by)
|
||||
VALUES (?, ?, ?, 'SUBMITTED', ?, ?, ?)`,
|
||||
[input.tenantId, input.taskId, revision, JSON.stringify(photoUrls),
|
||||
(input.note ?? '').slice(0, 512), input.userId]
|
||||
);
|
||||
if (ownedPhotos.length > 0) {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_photos
|
||||
SET status = 'ATTACHED', attached_revision = ?, retention_until = '9999-12-31 23:59:59.999'
|
||||
WHERE tenant_id = ? AND task_id = ? AND id IN (${ownedPhotos.map(() => '?').join(',')})`,
|
||||
[revision, input.tenantId, input.taskId, ...ownedPhotos.map((photo) => photo.id)]
|
||||
);
|
||||
}
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''
|
||||
);
|
||||
@@ -854,14 +1067,106 @@ export class CleaningTaskRepository {
|
||||
|
||||
async assertCanUploadPhoto(input: CleaningActor & { taskId: string }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
const [rows] = await this.pool.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
const [rows] = await this.pool.execute<CurrentTaskRuleRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
rework_count AS reworkCount
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status IN ('STARTED', 'REJECTED')
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = 'STARTED'
|
||||
AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
return { storeId: String(rows[0].storeId), maxPhotoCount: Number(rows[0].maxPhotoCount) };
|
||||
}
|
||||
|
||||
async recordPhotoUpload(input: CleaningActor & { taskId: string; image: {
|
||||
storagePath: string;
|
||||
publicUrl: string;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
checksumSha256: string;
|
||||
} }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
rework_count AS reworkCount
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ?
|
||||
AND status = 'STARTED' AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
const task = rows[0];
|
||||
if (!task) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
const [counts] = await connection.execute<CountRow[]>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_cleaning_task_photos
|
||||
WHERE tenant_id = ? AND task_id = ? AND uploaded_by = ?
|
||||
AND status = 'PENDING' AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
if (Number(counts[0]?.total ?? 0) >= Number(task.maxPhotoCount)) {
|
||||
throw new CleaningTaskError('CLEANING_PHOTO_COUNT_INVALID');
|
||||
}
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_cleaning_task_photos
|
||||
(tenant_id, task_id, store_id, uploaded_by, storage_path, public_url,
|
||||
mime_type, byte_size, width, height, checksum_sha256, status, retention_until)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING',
|
||||
TIMESTAMPADD(DAY, 7, UTC_TIMESTAMP(3)))`,
|
||||
[input.tenantId, input.taskId, task.storeId, input.userId,
|
||||
input.image.storagePath, input.image.publicUrl, input.image.mimeType,
|
||||
input.image.byteSize, input.image.width, input.image.height, input.image.checksumSha256]
|
||||
);
|
||||
return { id: String(result.insertId), ...input.image };
|
||||
});
|
||||
}
|
||||
|
||||
async claimExpiredPhotoUploads(input: CleaningActor & { limit: number }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
const safeLimit = Math.max(1, Math.min(100, Math.trunc(input.limit)));
|
||||
return this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<ExpiredPhotoRow[]>(
|
||||
`SELECT p.id, p.storage_path AS storagePath
|
||||
FROM qipai_cleaning_task_photos p
|
||||
WHERE p.tenant_id = ? AND p.status IN ('PENDING', 'ORPHANED')
|
||||
AND p.retention_until <= UTC_TIMESTAMP(3) AND p.deleted_at IS NULL
|
||||
AND ${storeScopeSql(input.access, 'p.store_id')}
|
||||
ORDER BY p.retention_until ASC, p.id ASC
|
||||
LIMIT ${safeLimit} FOR UPDATE SKIP LOCKED`,
|
||||
[input.tenantId]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_photos
|
||||
SET status = 'ORPHANED', retention_until = TIMESTAMPADD(MINUTE, 5, UTC_TIMESTAMP(3))
|
||||
WHERE tenant_id = ? AND id IN (${rows.map(() => '?').join(',')})`,
|
||||
[input.tenantId, ...rows.map((row) => row.id)]
|
||||
);
|
||||
}
|
||||
return rows.map((row) => ({ id: String(row.id), storagePath: row.storagePath }));
|
||||
});
|
||||
}
|
||||
|
||||
async markPhotoUploadsDeleted(input: CleaningActor & { photoIds: string[] }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
if (input.photoIds.length === 0) return { deleted: 0 };
|
||||
const uniqueIds = [...new Set(input.photoIds)];
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_task_photos p
|
||||
SET p.deleted_at = UTC_TIMESTAMP(3), p.retention_until = UTC_TIMESTAMP(3)
|
||||
WHERE p.tenant_id = ? AND p.status = 'ORPHANED' AND p.deleted_at IS NULL
|
||||
AND p.id IN (${uniqueIds.map(() => '?').join(',')})
|
||||
AND ${storeScopeSql(input.access, 'p.store_id')}`,
|
||||
[input.tenantId, ...uniqueIds]
|
||||
);
|
||||
return { deleted: result.affectedRows };
|
||||
}
|
||||
|
||||
async createForFinishedOrder(
|
||||
@@ -877,23 +1182,45 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
const order = orders[0];
|
||||
if (!order) throw new CleaningTaskError('CLEANING_ORDER_NOT_FINISHED');
|
||||
const [templates] = await connection.execute<CleaningTemplateRow[]>(
|
||||
`SELECT id, scope_key AS scopeKey, scope_type AS scopeType,
|
||||
store_id AS storeId, room_id AS roomId, name, requirement,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
version, status, updated_at AS updatedAt
|
||||
FROM qipai_cleaning_templates
|
||||
WHERE tenant_id = ? AND status = 'ACTIVE'
|
||||
AND ((scope_type = 'ROOM' AND room_id = ?)
|
||||
OR (scope_type = 'STORE' AND store_id = ?)
|
||||
OR scope_type = 'TENANT')
|
||||
ORDER BY FIELD(scope_type, 'ROOM', 'STORE', 'TENANT')
|
||||
LIMIT 1`,
|
||||
[input.tenantId, order.roomId, order.storeId]
|
||||
);
|
||||
const template = templates[0];
|
||||
const taskNo = `CLN-${order.orderNo}`;
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO qipai_cleaning_tasks
|
||||
(tenant_id, store_id, room_id, order_id, task_no, status, priority,
|
||||
reward_cents, requirement, photo_urls_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, '订单结束后保洁', JSON_ARRAY())`,
|
||||
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo]
|
||||
reward_cents, requirement, cleaning_template_id, cleaning_template_version,
|
||||
photo_required, min_photo_count, max_photo_count, exempt_policy, photo_urls_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, ?, ?, ?, ?, ?, ?, ?, JSON_ARRAY())`,
|
||||
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo,
|
||||
template?.requirement || '订单结束后清洁', template?.id ?? null,
|
||||
Number(template?.version ?? 0), template ? (template.photoRequired ? 1 : 0) : 1,
|
||||
Number(template?.minPhotoCount ?? 1), Number(template?.maxPhotoCount ?? 9),
|
||||
template?.exemptPolicy ?? 'ANY_ACTIVE']
|
||||
);
|
||||
if (result.affectedRows === 1) {
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_cleaning_task_events
|
||||
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
||||
SELECT tenant_id, id, NULL, 'WAITING', 'AUTO_CREATE', ?, ?, '订单结束自动创建保洁任务',
|
||||
JSON_OBJECT('orderId', ?)
|
||||
JSON_OBJECT('orderId', ?, 'cleaningTemplateId', ?, 'cleaningTemplateVersion', ?)
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[input.actorId, input.traceId, input.orderId, input.tenantId, input.orderId]
|
||||
[input.actorId, input.traceId, input.orderId, template?.id ?? null,
|
||||
Number(template?.version ?? 0), input.tenantId, input.orderId]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1106,7 +1433,14 @@ export class CleaningTaskRepository {
|
||||
t.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
|
||||
t.order_id AS orderId, o.order_no AS orderNo, t.status,
|
||||
t.cleaner_user_id AS cleanerUserId, t.priority, t.reward_cents AS rewardCents,
|
||||
t.requirement, t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
|
||||
t.requirement, t.cleaning_template_id AS cleaningTemplateId,
|
||||
t.cleaning_template_version AS cleaningTemplateVersion,
|
||||
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
|
||||
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
|
||||
t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
|
||||
t.rework_count AS reworkCount,
|
||||
COALESCE((SELECT MAX(sub.revision) FROM qipai_cleaning_task_submissions sub
|
||||
WHERE sub.tenant_id = t.tenant_id AND sub.task_id = t.id), 0) AS photoRevision,
|
||||
t.claimed_at AS claimedAt, t.started_at AS startedAt,
|
||||
t.submitted_at AS submittedAt, t.completed_at AS completedAt,
|
||||
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
|
||||
@@ -1175,7 +1509,7 @@ export class CleaningTaskRepository {
|
||||
action: string,
|
||||
timestampColumn: 'started_at' | 'submitted_at',
|
||||
note: string,
|
||||
extra?: { photo_urls_json?: string; reject_reason?: string }
|
||||
extra?: { photo_urls_json?: string; reject_reason?: string; incrementRework?: boolean }
|
||||
) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
const extraAssignments: string[] = [];
|
||||
@@ -1188,6 +1522,7 @@ export class CleaningTaskRepository {
|
||||
extraAssignments.push('reject_reason = ?');
|
||||
extraParams.push(extra.reject_reason);
|
||||
}
|
||||
if (extra?.incrementRework) extraAssignments.push('rework_count = rework_count + 1');
|
||||
const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : '';
|
||||
await this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
@@ -1280,6 +1615,61 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
}
|
||||
|
||||
private async getTemplate(
|
||||
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
|
||||
tenantId: string,
|
||||
templateId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<CleaningTemplateRow[]>(
|
||||
`SELECT id, scope_key AS scopeKey, scope_type AS scopeType,
|
||||
store_id AS storeId, room_id AS roomId, name, requirement,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
version, status, updated_at AS updatedAt
|
||||
FROM qipai_cleaning_templates
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[tenantId, templateId]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningTaskError('CLEANING_TEMPLATE_NOT_FOUND');
|
||||
return publicTemplate(rows[0]);
|
||||
}
|
||||
|
||||
private async resolveTemplateScope(
|
||||
input: CleaningActor,
|
||||
scopeType: CleaningTemplateScope,
|
||||
scopeId?: string
|
||||
) {
|
||||
if (scopeType === 'TENANT') {
|
||||
if (scopeId || (!input.access.capabilities.includes('tenant.manage')
|
||||
&& !input.access.roles.includes('PLATFORM_ADMIN'))) {
|
||||
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
return { scopeKey: 'TENANT', storeId: null, roomId: null };
|
||||
}
|
||||
if (!scopeId) throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_INVALID');
|
||||
if (scopeType === 'STORE') {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_stores
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, scopeId]
|
||||
);
|
||||
if (!rows[0] || !canAccessStore(input.access, scopeId)) {
|
||||
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
return { scopeKey: `STORE:${scopeId}`, storeId: scopeId, roomId: null };
|
||||
}
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT store_id AS storeId FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, scopeId]
|
||||
);
|
||||
const storeId = rows[0]?.storeId ? String(rows[0].storeId) : '';
|
||||
if (!storeId || !canAccessStore(input.access, storeId)) {
|
||||
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
return { scopeKey: `ROOM:${scopeId}`, storeId, roomId: scopeId };
|
||||
}
|
||||
|
||||
private async assertStoreVisible(input: CleaningActor, taskId: string) {
|
||||
if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
@@ -1417,6 +1807,12 @@ export class CleaningTaskRepository {
|
||||
}
|
||||
}
|
||||
|
||||
function canAccessStore(access: AccessProfile, storeId: string) {
|
||||
return access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN')
|
||||
|| access.storeIds.includes(storeId);
|
||||
}
|
||||
|
||||
function storeScopeSql(access: AccessProfile, storeExpression: string) {
|
||||
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
|
||||
return '1 = 1';
|
||||
@@ -1441,8 +1837,16 @@ function publicTask(row: CleaningTaskRow) {
|
||||
priority: Number(row.priority),
|
||||
rewardCents: Number(row.rewardCents),
|
||||
requirement: row.requirement,
|
||||
cleaningTemplateId: row.cleaningTemplateId === null ? null : String(row.cleaningTemplateId),
|
||||
cleaningTemplateVersion: Number(row.cleaningTemplateVersion),
|
||||
photoRequired: Boolean(row.photoRequired),
|
||||
minPhotoCount: Number(row.minPhotoCount),
|
||||
maxPhotoCount: Number(row.maxPhotoCount),
|
||||
exemptPolicy: row.exemptPolicy,
|
||||
photoUrls: parseJsonArray(row.photoUrlsJson),
|
||||
rejectReason: row.rejectReason,
|
||||
reworkCount: Number(row.reworkCount),
|
||||
photoRevision: Number(row.photoRevision),
|
||||
claimedAt: row.claimedAt,
|
||||
startedAt: row.startedAt,
|
||||
submittedAt: row.submittedAt,
|
||||
@@ -1453,6 +1857,26 @@ function publicTask(row: CleaningTaskRow) {
|
||||
};
|
||||
}
|
||||
|
||||
function publicTemplate(row: CleaningTemplateRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
scopeKey: row.scopeKey,
|
||||
scopeType: row.scopeType,
|
||||
storeId: row.storeId === null ? null : String(row.storeId),
|
||||
roomId: row.roomId === null ? null : String(row.roomId),
|
||||
name: row.name,
|
||||
requirement: row.requirement,
|
||||
photoRequired: Boolean(row.photoRequired),
|
||||
minPhotoCount: Number(row.minPhotoCount),
|
||||
maxPhotoCount: Number(row.maxPhotoCount),
|
||||
exemptPolicy: row.exemptPolicy,
|
||||
version: Number(row.version),
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAt,
|
||||
appliesToExistingTasks: false
|
||||
};
|
||||
}
|
||||
|
||||
function publicTaskMember(row: CleaningTaskMemberRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
@@ -1481,6 +1905,22 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
|
||||
};
|
||||
}
|
||||
|
||||
function publicTaskSubmission(row: CleaningTaskSubmissionRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
taskId: String(row.taskId),
|
||||
revision: Number(row.revision),
|
||||
status: row.status,
|
||||
photoUrls: parseJsonArray(row.photoUrlsJson),
|
||||
note: row.note,
|
||||
rejectReason: row.rejectReason,
|
||||
submittedBy: String(row.submittedBy),
|
||||
reviewedBy: row.reviewedBy === null ? null : String(row.reviewedBy),
|
||||
submittedAt: row.submittedAt,
|
||||
reviewedAt: row.reviewedAt
|
||||
};
|
||||
}
|
||||
|
||||
function publicSettlement(row: SettlementRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { extname, resolve, sep } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
@@ -33,18 +33,33 @@ export class MediaStorage {
|
||||
if (input.body.length === 0 || input.body.length > 8 * 1024 * 1024) {
|
||||
throw new MediaValidationError('IMAGE_SIZE_INVALID');
|
||||
}
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.contentType)) {
|
||||
const declaredFormat = {
|
||||
'image/jpeg': 'jpeg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp'
|
||||
}[input.contentType];
|
||||
if (!declaredFormat) {
|
||||
throw new MediaValidationError('IMAGE_TYPE_INVALID');
|
||||
}
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(extname(input.originalName).toLowerCase())) {
|
||||
const declaredExtension = {
|
||||
'.jpg': 'jpeg',
|
||||
'.jpeg': 'jpeg',
|
||||
'.png': 'png',
|
||||
'.webp': 'webp'
|
||||
}[extname(input.originalName).toLowerCase()];
|
||||
if (!declaredExtension) {
|
||||
throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
|
||||
}
|
||||
if (declaredExtension !== declaredFormat) {
|
||||
throw new MediaValidationError('IMAGE_TYPE_MISMATCH');
|
||||
}
|
||||
let result: Buffer;
|
||||
let metadata: sharp.Metadata;
|
||||
try {
|
||||
const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 });
|
||||
metadata = await source.metadata();
|
||||
if (!metadata.width || !metadata.height) throw new Error('missing dimensions');
|
||||
if (metadata.format !== declaredFormat) throw new Error('declared image type mismatch');
|
||||
result = await source
|
||||
.rotate()
|
||||
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
|
||||
@@ -74,4 +89,17 @@ export class MediaStorage {
|
||||
checksumSha256: createHash('sha256').update(result).digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
async deleteImage(storagePath: string): Promise<void> {
|
||||
const safeRoot = resolve(this.root);
|
||||
const target = resolve(safeRoot, storagePath);
|
||||
if (target === safeRoot || !target.startsWith(`${safeRoot}${sep}`)) {
|
||||
throw new MediaValidationError('IMAGE_PATH_INVALID');
|
||||
}
|
||||
try {
|
||||
await unlink(target);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql'
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -88,9 +89,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql'
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
||||
|
||||
@@ -60,12 +60,17 @@ export class OrderDeviceAutomationService {
|
||||
}
|
||||
if (payload.event === 'ORDER_ROOM_CHANGED') {
|
||||
if (payload.previousRoomId && payload.previousRoomId !== order.roomId) {
|
||||
if (!await this.hasActiveRoomOrder(order.tenantId, payload.previousRoomId, order.id)) {
|
||||
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
|
||||
}
|
||||
}
|
||||
await this.startOrderDevices(order, payload.traceId);
|
||||
return { orderId: order.id, action: 'ROOM_CHANGED' };
|
||||
}
|
||||
|
||||
if (await this.hasActiveRoomOrder(order.tenantId, order.roomId, order.id)) {
|
||||
return { orderId: order.id, skipped: true, reason: 'ROOM_HAS_ACTIVE_ORDER' };
|
||||
}
|
||||
await this.cancelRoomDevices(order, payload.traceId, order.roomId);
|
||||
return { orderId: order.id, action: 'CANCELLED' };
|
||||
}
|
||||
@@ -112,6 +117,17 @@ export class OrderDeviceAutomationService {
|
||||
};
|
||||
}
|
||||
|
||||
private async hasActiveRoomOrder(tenantId: string, roomId: string, excludedOrderId: string) {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_orders
|
||||
WHERE tenant_id = ? AND room_id = ? AND id <> ?
|
||||
AND status IN ('PAID', 'RESERVED', 'IN_PROGRESS') AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[tenantId, roomId, excludedOrderId]
|
||||
);
|
||||
return Boolean(rows[0]);
|
||||
}
|
||||
|
||||
private async loadOrder(tenantId: string, orderId: string) {
|
||||
const [rows] = await this.pool.execute<OrderRow[]>(
|
||||
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
|
||||
|
||||
@@ -52,6 +52,26 @@ const memberRemoveSchema = z.object({
|
||||
const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
|
||||
const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict();
|
||||
const exemptSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
|
||||
const cleaningTemplateSchema = z.object({
|
||||
scopeType: z.enum(['TENANT', 'STORE', 'ROOM']),
|
||||
scopeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
requirement: z.string().trim().max(512).default(''),
|
||||
photoRequired: z.boolean().default(true),
|
||||
minPhotoCount: z.coerce.number().int().min(0).max(9).default(1),
|
||||
maxPhotoCount: z.coerce.number().int().min(1).max(9).default(9),
|
||||
exemptPolicy: z.enum(['DISABLED', 'BEFORE_START', 'ANY_ACTIVE']).default('ANY_ACTIVE'),
|
||||
status: z.enum(['ACTIVE', 'DISABLED']).default('ACTIVE')
|
||||
}).strict().superRefine((value, context) => {
|
||||
if ((value.scopeType === 'TENANT') !== !value.scopeId) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['scopeId'], message: 'scope mismatch' });
|
||||
}
|
||||
if (value.minPhotoCount > value.maxPhotoCount
|
||||
|| (value.photoRequired && value.minPhotoCount < 1)
|
||||
|| (!value.photoRequired && value.minPhotoCount !== 0)) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['minPhotoCount'], message: 'photo rule mismatch' });
|
||||
}
|
||||
});
|
||||
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
|
||||
const settlementPayoutStateSchema = z.enum([
|
||||
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM'
|
||||
@@ -101,14 +121,19 @@ const reclaimSchema = z.object({
|
||||
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||
});
|
||||
const photoCleanupSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||
}).strict();
|
||||
|
||||
export interface CleaningRouteOptions {
|
||||
repository: Pick<CleaningTaskRepository,
|
||||
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
||||
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
|
||||
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
|
||||
| 'assertCanUploadPhoto' | 'stats' | 'managerStatistics'>;
|
||||
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
|
||||
| 'listTemplates' | 'upsertTemplate'
|
||||
| 'stats' | 'managerStatistics'>;
|
||||
mediaStorage?: MediaStorage;
|
||||
payoutService?: Pick<CleaningPayoutService,
|
||||
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
|
||||
@@ -130,6 +155,28 @@ export async function registerCleaningRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
app.get('/admin-api/cleaning/templates', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listTemplates(actor),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/cleaning/templates', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const body = cleaningTemplateSchema.safeParse(request.body ?? {});
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.upsertTemplate({ ...actor, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
@@ -291,6 +338,18 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/tasks/:taskId/submissions', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listSubmissions({ ...actor, taskId: params.data.taskId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
@@ -366,14 +425,25 @@ export async function registerCleaningRoutes(
|
||||
return invalid(reply, request.traceId);
|
||||
}
|
||||
return handle(reply, request.traceId, async () => {
|
||||
await options.repository.assertCanUploadPhoto({ ...actor, taskId: params.data.taskId });
|
||||
const context = await options.repository.assertCanUploadPhoto({
|
||||
...actor, taskId: params.data.taskId
|
||||
});
|
||||
const image = await options.mediaStorage!.storeImage({
|
||||
tenantId: actor.tenantId,
|
||||
storeId: context.storeId,
|
||||
originalName,
|
||||
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
|
||||
body: request.body as Buffer
|
||||
});
|
||||
return reply.status(201).send({ code: 0, data: image, traceId: request.traceId });
|
||||
try {
|
||||
const recorded = await options.repository.recordPhotoUpload({
|
||||
...actor, taskId: params.data.taskId, image
|
||||
});
|
||||
return reply.status(201).send({ code: 0, data: recorded, traceId: request.traceId });
|
||||
} catch (error) {
|
||||
await options.mediaStorage!.deleteImage(image.storagePath);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,6 +543,41 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/photos/cleanup', async (request, reply) => {
|
||||
if (!options.mediaStorage) return reply.status(501).send({
|
||||
code: 'CLEANING_PHOTO_CLEANUP_UNAVAILABLE',
|
||||
message: 'Cleaning photo storage is not configured.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const body = photoCleanupSchema.safeParse(request.body ?? {});
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => {
|
||||
const claimed = await options.repository.claimExpiredPhotoUploads({
|
||||
...actor, limit: body.data.limit
|
||||
});
|
||||
const deletedIds: string[] = [];
|
||||
const failedIds: string[] = [];
|
||||
for (const photo of claimed) {
|
||||
try {
|
||||
await options.mediaStorage!.deleteImage(photo.storagePath);
|
||||
deletedIds.push(photo.id);
|
||||
} catch {
|
||||
failedIds.push(photo.id);
|
||||
}
|
||||
}
|
||||
const result = await options.repository.markPhotoUploadsDeleted({
|
||||
...actor, photoIds: deletedIds
|
||||
});
|
||||
return {
|
||||
code: 0,
|
||||
data: { claimed: claimed.length, deleted: result.deleted, failedIds },
|
||||
traceId: request.traceId
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
@@ -679,8 +784,7 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
||||
&& !(error instanceof CleaningPayoutError)
|
||||
&& !(error instanceof WechatPayError)) throw error;
|
||||
const code = error.code;
|
||||
const statusCode = code === 'CLEANING_TASK_FORBIDDEN'
|
||||
|| code === 'CLEANING_SETTLEMENT_FORBIDDEN' ? 403 : 409;
|
||||
const statusCode = code.endsWith('_FORBIDDEN') ? 403 : 409;
|
||||
return reply.status(statusCode).send({
|
||||
code,
|
||||
message: 'The cleaning task request cannot be completed.',
|
||||
|
||||
@@ -77,6 +77,25 @@ const app = await buildApp({
|
||||
},
|
||||
async assertCanUploadPhoto(input) {
|
||||
calls.push(['assertCanUploadPhoto', input]);
|
||||
return { storeId: '11', maxPhotoCount: 3 };
|
||||
},
|
||||
async recordPhotoUpload(input) {
|
||||
calls.push(['recordPhotoUpload', input]);
|
||||
return { id: '701', ...input.image };
|
||||
},
|
||||
async listTemplates(input) {
|
||||
calls.push(['listTemplates', input]);
|
||||
return [{
|
||||
id: '801', scopeKey: 'STORE:11', scopeType: 'STORE', storeId: '11', roomId: null,
|
||||
name: '门店清洁', requirement: '桌面与地面', photoRequired: true,
|
||||
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START',
|
||||
version: 2, status: 'ACTIVE', appliesToExistingTasks: false
|
||||
}];
|
||||
},
|
||||
async upsertTemplate(input) {
|
||||
calls.push(['upsertTemplate', input]);
|
||||
return { id: '801', scopeKey: `STORE:${input.scopeId}`, ...input,
|
||||
storeId: input.scopeId, roomId: null, version: 3, appliesToExistingTasks: false };
|
||||
},
|
||||
async submit(input) {
|
||||
calls.push(['submit', input]);
|
||||
@@ -116,6 +135,15 @@ const app = await buildApp({
|
||||
createdAt: new Date()
|
||||
}];
|
||||
},
|
||||
async listSubmissions(input) {
|
||||
calls.push(['listSubmissions', input]);
|
||||
return [{
|
||||
id: '9101', taskId: input.taskId, revision: 2, status: 'REJECTED',
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/old.webp'], note: 'first',
|
||||
rejectReason: 'photo is unclear', submittedBy: '31', reviewedBy: '31',
|
||||
submittedAt: new Date(), reviewedAt: new Date()
|
||||
}];
|
||||
},
|
||||
async addMember(input) {
|
||||
calls.push(['addMember', input]);
|
||||
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
|
||||
@@ -171,6 +199,14 @@ const app = await buildApp({
|
||||
calls.push(['reclaimTimeouts', input]);
|
||||
return { reclaimed: 2, taskIds: ['101', '102'] };
|
||||
},
|
||||
async claimExpiredPhotoUploads(input) {
|
||||
calls.push(['claimExpiredPhotoUploads', input]);
|
||||
return [{ id: '701', storagePath: 'tenants/7/stores/11/orphan.webp' }];
|
||||
},
|
||||
async markPhotoUploadsDeleted(input) {
|
||||
calls.push(['markPhotoUploadsDeleted', input]);
|
||||
return { deleted: input.photoIds.length };
|
||||
},
|
||||
async stats(input) {
|
||||
calls.push(['stats', input]);
|
||||
return {
|
||||
@@ -281,19 +317,44 @@ const app = await buildApp({
|
||||
async storeImage(input) {
|
||||
calls.push(['storeImage', input]);
|
||||
return {
|
||||
storagePath: 'tenants/7/shared/cleaning.webp',
|
||||
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp',
|
||||
storagePath: 'tenants/7/stores/11/cleaning.webp',
|
||||
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp',
|
||||
mimeType: 'image/webp',
|
||||
byteSize: input.body.length,
|
||||
width: 640,
|
||||
height: 480,
|
||||
checksumSha256: 'abc'
|
||||
};
|
||||
},
|
||||
async deleteImage(storagePath) {
|
||||
calls.push(['deleteImage', storagePath]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const templates = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/templates',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(templates.statusCode, 200);
|
||||
assert.equal(templates.json().data[0].scopeKey, 'STORE:11');
|
||||
|
||||
const updatedTemplate = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/admin-api/cleaning/templates',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
scopeType: 'STORE', scopeId: '11', name: '门店清洁', requirement: '桌面与地面',
|
||||
photoRequired: true, minPhotoCount: 2, maxPhotoCount: 3,
|
||||
exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
|
||||
}
|
||||
});
|
||||
assert.equal(updatedTemplate.statusCode, 200);
|
||||
assert.equal(updatedTemplate.json().data.appliesToExistingTasks, false);
|
||||
assert.equal(calls.at(-1)[0], 'upsertTemplate');
|
||||
|
||||
const hall = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
|
||||
@@ -396,9 +457,11 @@ const photo = await app.inject({
|
||||
payload: Buffer.from('fake-image')
|
||||
});
|
||||
assert.equal(photo.statusCode, 201);
|
||||
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp');
|
||||
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto');
|
||||
assert.equal(calls.at(-1)[0], 'storeImage');
|
||||
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp');
|
||||
assert.equal(calls.at(-3)[0], 'assertCanUploadPhoto');
|
||||
assert.equal(calls.at(-2)[0], 'storeImage');
|
||||
assert.equal(calls.at(-2)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[0], 'recordPhotoUpload');
|
||||
|
||||
const submit = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -498,6 +561,16 @@ assert.equal(calls.at(-1)[0], 'listEvents');
|
||||
assert.equal(calls.at(-1)[1].taskId, '101');
|
||||
assert.equal(events.json().data[0].action, 'SUBMIT');
|
||||
|
||||
const submissions = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks/101/submissions',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(submissions.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listSubmissions');
|
||||
assert.equal(submissions.json().data[0].revision, 2);
|
||||
assert.equal(submissions.json().data[0].photoUrls[0], 'https://api.txyundm.cn/uploads/old.webp');
|
||||
|
||||
const addedMember = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/members',
|
||||
@@ -664,6 +737,19 @@ assert.equal(reclaimed.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
|
||||
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
|
||||
|
||||
const cleanedPhotos = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/photos/cleanup',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { limit: 10 }
|
||||
});
|
||||
assert.equal(cleanedPhotos.statusCode, 200);
|
||||
assert.deepEqual(cleanedPhotos.json().data, { claimed: 1, deleted: 1, failedIds: [] });
|
||||
assert.equal(calls.at(-3)[0], 'claimExpiredPhotoUploads');
|
||||
assert.equal(calls.at(-2)[0], 'deleteImage');
|
||||
assert.equal(calls.at(-1)[0], 'markPhotoUploadsDeleted');
|
||||
assert.deepEqual(calls.at(-1)[1].photoIds, ['701']);
|
||||
|
||||
const stats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/stats',
|
||||
|
||||
@@ -21,7 +21,18 @@ try {
|
||||
assert.equal(image.mimeType, 'image/webp');
|
||||
assert.ok(image.width <= 1920);
|
||||
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/);
|
||||
assert.ok((await readFile(join(root, ...image.storagePath.split('/')))).length > 0);
|
||||
const storedPath = join(root, ...image.storagePath.split('/'));
|
||||
const storedBytes = await readFile(storedPath);
|
||||
assert.ok(storedBytes.length > 0);
|
||||
const storedMetadata = await sharp(storedBytes).metadata();
|
||||
assert.equal(storedMetadata.exif, undefined);
|
||||
assert.equal(storedMetadata.icc, undefined);
|
||||
await assert.rejects(
|
||||
() => storage.storeImage({
|
||||
tenantId: '7', originalName: 'spoofed.jpg', contentType: 'image/jpeg', body: png
|
||||
}),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_DECODE_FAILED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => storage.storeImage({
|
||||
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
|
||||
@@ -29,6 +40,12 @@ try {
|
||||
}),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID'
|
||||
);
|
||||
await storage.deleteImage(image.storagePath);
|
||||
await assert.rejects(() => readFile(storedPath), (error) => error.code === 'ENOENT');
|
||||
await assert.rejects(
|
||||
() => storage.deleteImage('../outside.webp'),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_PATH_INVALID'
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_l
|
||||
const adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
|
||||
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.sql');
|
||||
const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_password_auth.verify.sql');
|
||||
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
|
||||
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
|
||||
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -489,7 +492,22 @@ assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
|
||||
assert.match(adminAuthUpSql, /'2026081004'/);
|
||||
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
|
||||
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/);
|
||||
for (const table of [
|
||||
'qipai_cleaning_templates', 'qipai_cleaning_task_photos', 'qipai_cleaning_task_submissions'
|
||||
]) {
|
||||
assert.match(cleaningRulesUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(cleaningRulesDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(cleaningRulesVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
for (const column of [
|
||||
'cleaning_template_id', 'photo_required', 'min_photo_count', 'max_photo_count',
|
||||
'exempt_policy', 'rework_count'
|
||||
]) assert.match(cleaningRulesUpSql, new RegExp(`ADD COLUMN ${column}`));
|
||||
assert.match(cleaningRulesUpSql, /uq_qipai_cleaning_submission_revision/);
|
||||
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
|
||||
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
|
||||
assert.match(cleaningRulesVerifySql, /'2026081005'/);
|
||||
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
||||
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
||||
|
||||
console.log('PASS: M01-B through M08-D migration contracts are present.');
|
||||
console.log('PASS: M01-B through M09-B migration contracts are present.');
|
||||
|
||||
@@ -43,7 +43,8 @@ assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
|
||||
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
|
||||
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
||||
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -51,7 +52,8 @@ const verifyPlan = await loadMigrationPlan('verify');
|
||||
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
||||
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql$/);
|
||||
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
|
||||
|
||||
const calls = [];
|
||||
const fakePool = {
|
||||
|
||||
@@ -60,6 +60,9 @@ const expectedTables = [
|
||||
'qipai_async_tasks',
|
||||
'qipai_audit_logs',
|
||||
'qipai_auth_sessions',
|
||||
'qipai_cleaning_task_photos',
|
||||
'qipai_cleaning_task_submissions',
|
||||
'qipai_cleaning_templates',
|
||||
'qipai_collection_accounts',
|
||||
'qipai_device_alerts',
|
||||
'qipai_device_channels',
|
||||
@@ -139,13 +142,13 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002', '2026081003', '2026081004']
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1833,7 +1836,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081004');
|
||||
assert.equal(overview.latestMigration.version, '2026081005');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -2528,6 +2531,19 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
[context.tenantId, taskId, userId, memberRole, rewardCents, removedAt, settledAt]
|
||||
);
|
||||
};
|
||||
const recordPhoto = async (taskId, userId, suffix) => repository.recordPhotoUpload({
|
||||
...cleanerActor(userId, `m09b-photo-${suffix}`),
|
||||
taskId,
|
||||
image: {
|
||||
storagePath: `tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
|
||||
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
|
||||
mimeType: 'image/webp',
|
||||
byteSize: 1024,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
checksumSha256: suffix.padEnd(64, '0').slice(0, 64)
|
||||
}
|
||||
});
|
||||
|
||||
const rollbackTaskId = await insertTask();
|
||||
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
|
||||
@@ -2650,9 +2666,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
await repository.start({
|
||||
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
|
||||
});
|
||||
const firstPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-first');
|
||||
await repository.submit({
|
||||
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId,
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/m09a-first.webp'], note: 'first submit'
|
||||
photoUrls: [firstPhoto.publicUrl], note: 'first submit'
|
||||
});
|
||||
await repository.reject({
|
||||
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
|
||||
@@ -2666,9 +2683,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
await repository.rework({
|
||||
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
|
||||
});
|
||||
const secondPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-second');
|
||||
await repository.submit({
|
||||
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId,
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/m09a-second.webp'], note: 'second submit'
|
||||
photoUrls: [secondPhoto.publicUrl], note: 'second submit'
|
||||
});
|
||||
const completeInput = {
|
||||
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
|
||||
@@ -2688,6 +2706,74 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
status: lifecycleRows[0].status,
|
||||
completedSet: Number(lifecycleRows[0].completedSet)
|
||||
}, { status: 'COMPLETED', completedSet: 1 });
|
||||
const [submissionRows] = await pool.query(
|
||||
`SELECT revision, status, JSON_UNQUOTE(JSON_EXTRACT(photo_urls_json, '$[0]')) AS photoUrl,
|
||||
reject_reason AS rejectReason
|
||||
FROM qipai_cleaning_task_submissions
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY revision`,
|
||||
[context.tenantId, lifecycleTaskId]
|
||||
);
|
||||
assert.deepEqual(submissionRows.map((row) => ({
|
||||
revision: Number(row.revision), status: row.status,
|
||||
photoUrl: row.photoUrl, rejectReason: row.rejectReason
|
||||
})), [
|
||||
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' },
|
||||
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' }
|
||||
]);
|
||||
const [photoRows] = await pool.query(
|
||||
`SELECT public_url AS publicUrl, status, attached_revision AS attachedRevision
|
||||
FROM qipai_cleaning_task_photos
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY attached_revision`,
|
||||
[context.tenantId, lifecycleTaskId]
|
||||
);
|
||||
assert.deepEqual(photoRows.map((row) => ({
|
||||
publicUrl: row.publicUrl, status: row.status, revision: Number(row.attachedRevision)
|
||||
})), [
|
||||
{ publicUrl: firstPhoto.publicUrl, status: 'ATTACHED', revision: 2 },
|
||||
{ publicUrl: secondPhoto.publicUrl, status: 'ATTACHED', revision: 3 }
|
||||
]);
|
||||
const submissionHistory = await repository.listSubmissions({
|
||||
...managerActor('m09b-submission-history'), taskId: lifecycleTaskId
|
||||
});
|
||||
assert.deepEqual(submissionHistory.map((submission) => ({
|
||||
revision: submission.revision,
|
||||
status: submission.status,
|
||||
photoUrl: submission.photoUrls[0],
|
||||
rejectReason: submission.rejectReason
|
||||
})), [
|
||||
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' },
|
||||
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' }
|
||||
]);
|
||||
|
||||
const cleanupTaskId = await insertTask({
|
||||
status: 'STARTED', cleanerUserId: firstCleanerId,
|
||||
claimedAt: new Date(), startedAt: new Date()
|
||||
});
|
||||
const expiredPhoto = await recordPhoto(cleanupTaskId, firstCleanerId, 'm09b-expired');
|
||||
await pool.query(
|
||||
`UPDATE qipai_cleaning_task_photos SET retention_until = TIMESTAMPADD(DAY, -1, UTC_TIMESTAMP(3))
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, expiredPhoto.id]
|
||||
);
|
||||
const expiredClaims = await repository.claimExpiredPhotoUploads({
|
||||
...managerActor('m09b-photo-cleanup-claim'), limit: 10
|
||||
});
|
||||
assert.deepEqual(expiredClaims, [{ id: expiredPhoto.id, storagePath: expiredPhoto.storagePath }]);
|
||||
assert.deepEqual(
|
||||
await repository.markPhotoUploadsDeleted({
|
||||
...managerActor('m09b-photo-cleanup-delete'), photoIds: [expiredPhoto.id]
|
||||
}),
|
||||
{ deleted: 1 }
|
||||
);
|
||||
const [deletedPhotoRows] = await pool.query(
|
||||
`SELECT status, deleted_at IS NOT NULL AS deleted
|
||||
FROM qipai_cleaning_task_photos WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, expiredPhoto.id]
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ status: deletedPhotoRows[0].status, deleted: Number(deletedPhotoRows[0].deleted) },
|
||||
{ status: 'ORPHANED', deleted: 1 }
|
||||
);
|
||||
const [lifecycleEventRows] = await pool.query(
|
||||
`SELECT action FROM qipai_cleaning_task_events
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY id`,
|
||||
@@ -2782,6 +2868,26 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
assert.equal(settledMemberRows[0].removedAt, null);
|
||||
assert.equal(Number(settledMemberRows[0].settled), 1);
|
||||
|
||||
const deniedExemptTaskId = await insertTask();
|
||||
await pool.query(
|
||||
`UPDATE qipai_cleaning_tasks SET exempt_policy = 'DISABLED'
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, deniedExemptTaskId]
|
||||
);
|
||||
await assert.rejects(
|
||||
() => repository.exempt({
|
||||
...managerActor('m09b-exempt-denied'), taskId: deniedExemptTaskId, note: 'not allowed'
|
||||
}),
|
||||
(error) => error instanceof CleaningTaskError && error.code === 'CLEANING_EXEMPT_POLICY_DENIED'
|
||||
);
|
||||
|
||||
const template = await repository.upsertTemplate({
|
||||
...managerActor('m09b-template-v1'), scopeType: 'TENANT', name: 'M09-B tenant default',
|
||||
requirement: '两张照片并在开始前决定免清洁', photoRequired: true,
|
||||
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
|
||||
});
|
||||
assert.equal(template.version, 1);
|
||||
|
||||
const [orderResult] = await pool.query(
|
||||
`INSERT INTO qipai_orders
|
||||
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
|
||||
@@ -2812,7 +2918,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
orderConnection.release();
|
||||
}
|
||||
const [generatedRows] = await pool.query(
|
||||
`SELECT t.status,
|
||||
`SELECT t.status, t.cleaning_template_id AS cleaningTemplateId,
|
||||
t.cleaning_template_version AS cleaningTemplateVersion,
|
||||
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
|
||||
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
|
||||
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
|
||||
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id
|
||||
AND e.action = 'AUTO_CREATE') AS eventCount
|
||||
@@ -2822,10 +2931,34 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
);
|
||||
assert.equal(generatedRows.length, 1);
|
||||
assert.equal(generatedRows[0].status, 'WAITING');
|
||||
assert.equal(String(generatedRows[0].cleaningTemplateId), template.id);
|
||||
assert.equal(Number(generatedRows[0].cleaningTemplateVersion), 1);
|
||||
assert.equal(Number(generatedRows[0].photoRequired), 1);
|
||||
assert.equal(Number(generatedRows[0].minPhotoCount), 2);
|
||||
assert.equal(Number(generatedRows[0].maxPhotoCount), 3);
|
||||
assert.equal(generatedRows[0].exemptPolicy, 'BEFORE_START');
|
||||
assert.equal(Number(generatedRows[0].eventCount), 1);
|
||||
|
||||
const updatedTemplate = await repository.upsertTemplate({
|
||||
...managerActor('m09b-template-v2'), scopeType: 'TENANT', name: 'M09-B tenant default',
|
||||
requirement: '新任务无需照片', photoRequired: false,
|
||||
minPhotoCount: 0, maxPhotoCount: 2, exemptPolicy: 'DISABLED', status: 'ACTIVE'
|
||||
});
|
||||
assert.equal(updatedTemplate.version, 2);
|
||||
const [snapshotRows] = await pool.query(
|
||||
`SELECT cleaning_template_version AS version, min_photo_count AS minPhotoCount,
|
||||
exempt_policy AS exemptPolicy
|
||||
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND order_id = ?`,
|
||||
[context.tenantId, finishedOrderId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
version: Number(snapshotRows[0].version),
|
||||
minPhotoCount: Number(snapshotRows[0].minPhotoCount),
|
||||
exemptPolicy: snapshotRows[0].exemptPolicy
|
||||
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
|
||||
|
||||
console.log(
|
||||
'PASS: M09-A cleaning claims, lifecycle events, rollback, reassignment, timeout reclaim and order generation are transactional.'
|
||||
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2875,7 +3008,8 @@ try {
|
||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' }
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -2904,7 +3038,7 @@ try {
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M06-C migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-B migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -2932,7 +3066,8 @@ try {
|
||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' }
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
@@ -3069,7 +3204,11 @@ try {
|
||||
'idempotent cleaning completion trace',
|
||||
'SKIP LOCKED cleaning timeout reclaim batch',
|
||||
'settled cleaning member preservation',
|
||||
'finished order creates one cleaning task'
|
||||
'finished order creates one cleaning task',
|
||||
'cleaning template snapshot remains stable after config version change',
|
||||
'rejected and accepted cleaning photo revisions remain distinguishable',
|
||||
'expired unattached cleaning photo retention cleanup',
|
||||
'task-level cleaning exemption policy denial'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { DeviceControlError } from '../dist/devices/device-control-service.js';
|
||||
|
||||
const calls = [];
|
||||
let activeRoomOrder = false;
|
||||
let currentOrder = {
|
||||
id: 31,
|
||||
tenantId: 7,
|
||||
@@ -17,6 +18,11 @@ let currentOrder = {
|
||||
};
|
||||
const service = new OrderDeviceAutomationService({
|
||||
async execute(sql, params) {
|
||||
if (sql.includes('id <> ?')) {
|
||||
assert.equal(params[0], '7');
|
||||
assert.equal(params[2], '31');
|
||||
return [activeRoomOrder ? [{ id: 32 }] : [], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_orders')) {
|
||||
assert.equal(params[0], '7');
|
||||
assert.equal(params[1], '31');
|
||||
@@ -91,6 +97,17 @@ await service.handleTask(task({
|
||||
assert.equal(calls.at(-2)[0], 'cancelTask');
|
||||
assert.equal(calls.at(-1)[2].on, false);
|
||||
|
||||
currentOrder = { ...currentOrder, status: 'FINISHED' };
|
||||
activeRoomOrder = true;
|
||||
const callCountBeforeProtectedFinish = calls.length;
|
||||
const protectedFinish = await service.handleTask(task({
|
||||
tenantId: '7', orderId: '31', event: 'ORDER_FINISHED', traceId: 'm09b-finish'
|
||||
}));
|
||||
assert.equal(protectedFinish.skipped, true);
|
||||
assert.equal(protectedFinish.reason, 'ROOM_HAS_ACTIVE_ORDER');
|
||||
assert.equal(calls.length, callCountBeforeProtectedFinish);
|
||||
activeRoomOrder = false;
|
||||
|
||||
currentOrder = { ...currentOrder, roomId: 52, status: 'IN_PROGRESS' };
|
||||
const changed = await service.handleTask(task({
|
||||
tenantId: '7',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
DELETE FROM qipai_schema_migrations WHERE version = '2026081005';
|
||||
|
||||
DROP TABLE IF EXISTS qipai_cleaning_task_submissions;
|
||||
DROP TABLE IF EXISTS qipai_cleaning_task_photos;
|
||||
|
||||
ALTER TABLE qipai_cleaning_tasks
|
||||
DROP FOREIGN KEY fk_qipai_cleaning_tasks_template,
|
||||
DROP CONSTRAINT chk_qipai_cleaning_task_photo_rule,
|
||||
DROP CONSTRAINT chk_qipai_cleaning_task_exempt_policy,
|
||||
DROP COLUMN rework_count,
|
||||
DROP COLUMN exempt_policy,
|
||||
DROP COLUMN max_photo_count,
|
||||
DROP COLUMN min_photo_count,
|
||||
DROP COLUMN photo_required,
|
||||
DROP COLUMN cleaning_template_version,
|
||||
DROP COLUMN cleaning_template_id;
|
||||
|
||||
DROP TABLE IF EXISTS qipai_cleaning_templates;
|
||||
@@ -0,0 +1,108 @@
|
||||
CREATE TABLE IF NOT EXISTS qipai_cleaning_templates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
scope_key VARCHAR(64) NOT NULL,
|
||||
scope_type VARCHAR(16) NOT NULL,
|
||||
store_id BIGINT UNSIGNED NULL,
|
||||
room_id BIGINT UNSIGNED NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
requirement VARCHAR(512) NOT NULL DEFAULT '',
|
||||
photo_required TINYINT(1) NOT NULL DEFAULT 1,
|
||||
min_photo_count TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
max_photo_count TINYINT UNSIGNED NOT NULL DEFAULT 9,
|
||||
exempt_policy VARCHAR(32) NOT NULL DEFAULT 'ANY_ACTIVE',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_by BIGINT UNSIGNED NOT NULL,
|
||||
updated_by BIGINT UNSIGNED NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_cleaning_templates_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_cleaning_templates_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_cleaning_templates_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||
CONSTRAINT fk_qipai_cleaning_templates_creator FOREIGN KEY (created_by) REFERENCES qipai_users(id),
|
||||
CONSTRAINT fk_qipai_cleaning_templates_updater FOREIGN KEY (updated_by) REFERENCES qipai_users(id),
|
||||
CONSTRAINT chk_qipai_cleaning_template_scope CHECK (scope_type IN ('TENANT', 'STORE', 'ROOM')),
|
||||
CONSTRAINT chk_qipai_cleaning_template_photos CHECK (
|
||||
min_photo_count <= max_photo_count AND max_photo_count <= 9
|
||||
AND ((photo_required = 1 AND min_photo_count >= 1) OR (photo_required = 0 AND min_photo_count = 0))
|
||||
),
|
||||
CONSTRAINT chk_qipai_cleaning_template_exempt CHECK (
|
||||
exempt_policy IN ('DISABLED', 'BEFORE_START', 'ANY_ACTIVE')
|
||||
),
|
||||
CONSTRAINT chk_qipai_cleaning_template_status CHECK (status IN ('ACTIVE', 'DISABLED')),
|
||||
UNIQUE KEY uq_qipai_cleaning_template_scope (tenant_id, scope_key),
|
||||
KEY idx_qipai_cleaning_template_resolve (tenant_id, room_id, store_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE qipai_cleaning_tasks
|
||||
ADD COLUMN cleaning_template_id BIGINT UNSIGNED NULL AFTER requirement,
|
||||
ADD COLUMN cleaning_template_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER cleaning_template_id,
|
||||
ADD COLUMN photo_required TINYINT(1) NOT NULL DEFAULT 1 AFTER cleaning_template_version,
|
||||
ADD COLUMN min_photo_count TINYINT UNSIGNED NOT NULL DEFAULT 1 AFTER photo_required,
|
||||
ADD COLUMN max_photo_count TINYINT UNSIGNED NOT NULL DEFAULT 9 AFTER min_photo_count,
|
||||
ADD COLUMN exempt_policy VARCHAR(32) NOT NULL DEFAULT 'ANY_ACTIVE' AFTER max_photo_count,
|
||||
ADD COLUMN rework_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER reject_reason,
|
||||
ADD CONSTRAINT fk_qipai_cleaning_tasks_template
|
||||
FOREIGN KEY (cleaning_template_id) REFERENCES qipai_cleaning_templates(id),
|
||||
ADD CONSTRAINT chk_qipai_cleaning_task_photo_rule CHECK (
|
||||
min_photo_count <= max_photo_count AND max_photo_count <= 9
|
||||
AND ((photo_required = 1 AND min_photo_count >= 1) OR (photo_required = 0 AND min_photo_count = 0))
|
||||
),
|
||||
ADD CONSTRAINT chk_qipai_cleaning_task_exempt_policy CHECK (
|
||||
exempt_policy IN ('DISABLED', 'BEFORE_START', 'ANY_ACTIVE')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_cleaning_task_photos (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
uploaded_by BIGINT UNSIGNED NOT NULL,
|
||||
storage_path VARCHAR(512) NOT NULL,
|
||||
public_url VARCHAR(700) NOT NULL,
|
||||
mime_type VARCHAR(64) NOT NULL,
|
||||
byte_size INT UNSIGNED NOT NULL,
|
||||
width INT UNSIGNED NOT NULL,
|
||||
height INT UNSIGNED NOT NULL,
|
||||
checksum_sha256 CHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||
attached_revision INT UNSIGNED NULL,
|
||||
retention_until DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_cleaning_photos_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_cleaning_photos_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id),
|
||||
CONSTRAINT fk_qipai_cleaning_photos_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_cleaning_photos_uploader FOREIGN KEY (uploaded_by) REFERENCES qipai_users(id),
|
||||
CONSTRAINT chk_qipai_cleaning_photo_status CHECK (status IN ('PENDING', 'ATTACHED', 'ORPHANED')),
|
||||
UNIQUE KEY uq_qipai_cleaning_photo_storage (tenant_id, storage_path),
|
||||
UNIQUE KEY uq_qipai_cleaning_photo_url (tenant_id, public_url),
|
||||
KEY idx_qipai_cleaning_photo_task (tenant_id, task_id, status, created_at),
|
||||
KEY idx_qipai_cleaning_photo_retention (status, retention_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_cleaning_task_submissions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
revision INT UNSIGNED NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED',
|
||||
photo_urls_json JSON NOT NULL,
|
||||
note VARCHAR(512) NOT NULL DEFAULT '',
|
||||
reject_reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
submitted_by BIGINT UNSIGNED NOT NULL,
|
||||
reviewed_by BIGINT UNSIGNED NULL,
|
||||
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
reviewed_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_cleaning_submissions_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_cleaning_submissions_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id),
|
||||
CONSTRAINT fk_qipai_cleaning_submissions_submitter FOREIGN KEY (submitted_by) REFERENCES qipai_users(id),
|
||||
CONSTRAINT fk_qipai_cleaning_submissions_reviewer FOREIGN KEY (reviewed_by) REFERENCES qipai_users(id),
|
||||
CONSTRAINT chk_qipai_cleaning_submission_status CHECK (status IN ('SUBMITTED', 'ACCEPTED', 'REJECTED')),
|
||||
UNIQUE KEY uq_qipai_cleaning_submission_revision (tenant_id, task_id, revision),
|
||||
KEY idx_qipai_cleaning_submission_review (tenant_id, task_id, status, submitted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026081005', 'm09b_cleaning_rules');
|
||||
@@ -0,0 +1,32 @@
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN (
|
||||
'qipai_cleaning_templates',
|
||||
'qipai_cleaning_task_photos',
|
||||
'qipai_cleaning_task_submissions'
|
||||
)
|
||||
ORDER BY table_name;
|
||||
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_tasks'
|
||||
AND column_name IN (
|
||||
'cleaning_template_id', 'cleaning_template_version', 'photo_required',
|
||||
'min_photo_count', 'max_photo_count', 'exempt_policy', 'rework_count'
|
||||
)
|
||||
ORDER BY column_name;
|
||||
|
||||
SELECT table_name, index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'qipai_cleaning_templates' AND index_name = 'uq_qipai_cleaning_template_scope')
|
||||
OR (table_name = 'qipai_cleaning_task_photos' AND index_name = 'uq_qipai_cleaning_photo_url')
|
||||
OR (table_name = 'qipai_cleaning_task_submissions' AND index_name = 'uq_qipai_cleaning_submission_revision'))
|
||||
GROUP BY table_name, index_name
|
||||
ORDER BY table_name, index_name;
|
||||
|
||||
SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version = '2026081005';
|
||||
@@ -62,9 +62,14 @@ Page({
|
||||
const taskId = event.currentTarget.dataset.taskId
|
||||
if (!taskId) return
|
||||
try {
|
||||
const result = await wxChooseMedia(9)
|
||||
const current = this.data.selectedPhotosByTask[taskId] || []
|
||||
const next = current.concat(result.tempFiles.map((item) => item.tempFilePath)).slice(0, 9)
|
||||
const maxPhotos = Math.max(1, Math.min(9, Number(event.currentTarget.dataset.maxPhotos || 9)))
|
||||
if (current.length >= maxPhotos) {
|
||||
this.setData({ errorMessage: `当前任务最多上传 ${maxPhotos} 张照片` })
|
||||
return
|
||||
}
|
||||
const result = await wxChooseMedia(maxPhotos - current.length)
|
||||
const next = current.concat(result.tempFiles.map((item) => item.tempFilePath)).slice(0, maxPhotos)
|
||||
this.setData({ [`selectedPhotosByTask.${taskId}`]: next })
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '选择照片失败' })
|
||||
@@ -74,8 +79,10 @@ Page({
|
||||
async submitTask(event) {
|
||||
const taskId = event.currentTarget.dataset.taskId
|
||||
const localPhotos = this.data.selectedPhotosByTask[taskId] || []
|
||||
if (localPhotos.length === 0) {
|
||||
this.setData({ errorMessage: '请先上传至少一张保洁照片' })
|
||||
const task = this.data.myTasks.find((item) => item.id === taskId)
|
||||
const minPhotos = Number(task?.minPhotoCount ?? 1)
|
||||
if (localPhotos.length < minPhotos) {
|
||||
this.setData({ errorMessage: `请先选择至少 ${minPhotos} 张保洁照片` })
|
||||
return
|
||||
}
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
@@ -125,7 +132,10 @@ function formatTask(task) {
|
||||
canStart: task.status === 'CLAIMED',
|
||||
canSubmit: task.status === 'STARTED',
|
||||
canRework: task.status === 'REJECTED',
|
||||
canUpload: task.status === 'STARTED' || task.status === 'REJECTED',
|
||||
canUpload: task.status === 'STARTED',
|
||||
photoRuleText: task.photoRequired
|
||||
? `至少 ${task.minPhotoCount} 张,最多 ${task.maxPhotoCount} 张`
|
||||
: `照片可选,最多 ${task.maxPhotoCount} 张`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
<button wx:if="{{item.canStart}}" data-task-id="{{item.id}}" bindtap="startTask">开始</button>
|
||||
<button wx:if="{{item.canRework}}" data-task-id="{{item.id}}" bindtap="reworkTask">重做</button>
|
||||
<view wx:if="{{item.canUpload}}" class="submit-box">
|
||||
<button data-task-id="{{item.id}}" bindtap="choosePhotos">选择照片</button>
|
||||
<view class="muted">至少 1 张,最多 9 张</view>
|
||||
<button data-task-id="{{item.id}}" data-max-photos="{{item.maxPhotoCount}}" bindtap="choosePhotos">选择照片</button>
|
||||
<view class="muted">{{item.photoRuleText}}</view>
|
||||
<button data-task-id="{{item.id}}" bindtap="submitTask">提交验收</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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.');
|
||||
@@ -32,6 +32,8 @@ node scripts/check-admin-m08-d-r1.mjs --source-only
|
||||
Assert-NativeSuccess "check-admin-m08-d-r1 source"
|
||||
node scripts/check-miniapp-m08-c.mjs
|
||||
Assert-NativeSuccess "check-miniapp-m08-c"
|
||||
node scripts/check-m09-b-cleaning-rules.mjs
|
||||
Assert-NativeSuccess "check-m09-b-cleaning-rules"
|
||||
|
||||
if (Test-Path "admin/package.json") {
|
||||
Push-Location admin
|
||||
|
||||
Reference in New Issue
Block a user