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