Compare commits
125 Commits
2bbb16e6f0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01e86e6856 | |||
| 3bee3c47b6 | |||
| 96c5fa4c90 | |||
| 1a1892cfb4 | |||
| 06ea496eb7 | |||
| de0013e329 | |||
| dabb71d8ab | |||
| 0f85422e82 | |||
| 053e27fd40 | |||
| a4c2d14c41 | |||
| c40cbcac11 | |||
| eb838f5a7e | |||
| 0bdcb220e5 | |||
| 642659ce3e | |||
| 19d6734b68 | |||
| 8ffb940bdb | |||
| 2a4db54b94 | |||
| 6979614aeb | |||
| eb0f58912e | |||
| 38410b1185 | |||
| ee39e98418 | |||
| 3713ddd1aa | |||
| 31052625ba | |||
| b66b8d4d78 | |||
| 650b9b917a | |||
| 85d65068e7 | |||
| e490637179 | |||
| bcc89addba | |||
| 7e8fa275d5 | |||
| 8a17659bdf | |||
| 98783714a3 | |||
| 9d8fb3db6b | |||
| c026ef7ebe | |||
| dc27897ea4 | |||
| 654f41fa40 | |||
| 05cf27f112 | |||
| b643fda99c | |||
| 1c1039ae0e | |||
| 0d0bdf20d5 | |||
| 6961193857 | |||
| 8c60c35444 | |||
| a720c05c9d | |||
| d0cf00309f | |||
| 2d07c92a88 | |||
| 38ef98b06b | |||
| a067c1d020 | |||
| 6c977632c0 | |||
| a09683b82d | |||
| 7a4797021b | |||
| a1adf4d6ab | |||
| 300e23c93c | |||
| 2e200842c9 | |||
| 31aed4835e | |||
| 464566a5e0 | |||
| 99d9d862b7 | |||
| 9f718ab154 | |||
| 1e930597b1 | |||
| a9bb06f732 | |||
| a8c3fde95a | |||
| 14fcfc2578 | |||
| 89b5263352 | |||
| 0eb6349021 | |||
| a95fdcbb93 | |||
| 1543a7df99 | |||
| 86106459c4 | |||
| 06f905e792 | |||
| 1f758cc29f | |||
| b5e7121c35 | |||
| fda768d4ce | |||
| a44864309c | |||
| 8e4264589d | |||
| 273e429d9e | |||
| 435c9f3f97 | |||
| 9ab3f5ff46 | |||
| 9346e7a18a | |||
| 0b3246ae8a | |||
| a50c6afdbe | |||
| d6bb9ccb1e | |||
| 44af0ee155 | |||
| 52fb508e03 | |||
| 8fae70ef8e | |||
| aa4f74360d | |||
| a264b37bd0 | |||
| c1165ccfd8 | |||
| b091c7caf1 | |||
| 8f7a914e7d | |||
| 08c152aecc | |||
| 983966561f | |||
| 2fa1f079df | |||
| 9f6e267d43 | |||
| 2ce59eee3b | |||
| 5eff0a4a18 | |||
| 986a90806b | |||
| 957a29b11e | |||
| 67e7796652 | |||
| 78fb6ed2d0 | |||
| e6bb5e6dc6 | |||
| e46d8c0f0a | |||
| dac353d184 | |||
| 335adcdc22 | |||
| d35c39e33f | |||
| 326c534ba7 | |||
| e48d1f0301 | |||
| f59beb0e75 | |||
| 9506021b29 | |||
| 1fada09249 | |||
| a1bace2dac | |||
| b07c451700 | |||
| 05907a6bea | |||
| 5d224693ae | |||
| 03496d74ab | |||
| 1dc36d716a | |||
| 2ec4376b1c | |||
| 704dca7bcf | |||
| 59ce3eca53 | |||
| c8ddc8dd00 | |||
| fe1a08880e | |||
| f7e0c8d063 | |||
| 2a4dcd0fd8 | |||
| 9f7c2e4be7 | |||
| 47016744d4 | |||
| 59c4ef330e | |||
| dda2b3cbc6 | |||
| 3d885852cd | |||
| 53f5d6776f |
@@ -19,8 +19,8 @@
|
||||
## 目录
|
||||
|
||||
- `backend/`:Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
|
||||
- `admin/`:Vue3 后台管理端,必须适配桌面、平板和手机
|
||||
- `miniapp/`:微信原生小程序
|
||||
- `admin/`:Vue3 后台管理端,已接入 M08-B 保洁运营工作台、运营待处理入口、运营待处理导出、待验收任务批量验收/驳回、免清洁标记、批量免清洁与统计展示、协作者管理、任务成员导出、任务详情照片预览、任务操作流水、任务流水导出、任务详情协作分账、任务列表导出、任务详情导出、结算详情、结算详情导出、待结算候选预览与导出、结算列表导出、结算批量同步微信状态、结算转账状态筛选、微信确认参数展示与导出、微信转账预检资料展示与导出、真实转账前自动预检拦截、统计明细、每日趋势、保洁员绩效排行、统计导出、保洁员资料管理、保洁员列表导出、保洁员待补资料导出、微信绑定状态、保洁员选择器、任务/结算/统计筛选、联调检查项搜索、联调筛选结果导出与筛选清除、联调负责人筛选、联调负责人看板、联调执行阶段看板、联调期限记录、联调逾期清单、联调风险清单导出和联调归档包导出,必须适配桌面、平板和手机
|
||||
- `miniapp/`:微信原生小程序,已接入顾客端主链路与保洁端任务大厅、我的任务、照片上传、驳回补做和保洁员统计看板
|
||||
- `database/`:迁移、种子和兼容 SQL
|
||||
- `deploy/`:Nginx、PM2、EMQX 模板与部署版本
|
||||
- `scripts/`:Windows、WSL 和 Ubuntu 辅助脚本
|
||||
@@ -120,9 +120,9 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
|
||||
|
||||
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
||||
|
||||
- 当前执行游标:`M08-A`(PARTIAL)
|
||||
- 最近工程提交:`e24920c`,完成顾客端优惠券/套餐明细和订单微信预支付调起。
|
||||
- 下一工程目标:继续 M08-A,补充值微信支付调起和权益下单抵扣集成。
|
||||
- 当前执行游标:`M08-B`(PARTIAL)
|
||||
- 最近工程提交:`96c5fa4` / `M08-B联调筛选清除增量`,本轮补齐现场联调分类、状态、负责人和关键字筛选的一键清除,不会重置已填写的联调草稿。
|
||||
- 下一工程目标:继续 M08-B,补更完整保洁运营管理界面和真实商户现场执行记录。
|
||||
|
||||
## 版本递进
|
||||
|
||||
@@ -137,3 +137,10 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
|
||||
请阅读 V5.4.md,按当前进度继续开发。
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_ADMIN_API_BASE_URL=https://api.txyundm.cn/admin-api
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>自助棋牌室后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1582
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@qipai/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.22.0",
|
||||
"element-plus": "^2.10.4",
|
||||
"vue": "^3.5.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.4.3",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
<template>
|
||||
<el-config-provider>
|
||||
<main class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">棋</div>
|
||||
<div>
|
||||
<h1>自助棋牌室</h1>
|
||||
<p>运营后台</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-list" aria-label="后台模块">
|
||||
<button class="nav-item active" type="button">
|
||||
<Sparkles :size="18" />
|
||||
<span>保洁运营</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" disabled>
|
||||
<Store :size="18" />
|
||||
<span>门店</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" disabled>
|
||||
<RadioTower :size="18" />
|
||||
<span>设备</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" disabled>
|
||||
<WalletCards :size="18" />
|
||||
<span>支付</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">M08-B</p>
|
||||
<h2>保洁任务与结算</h2>
|
||||
</div>
|
||||
<div class="token-box">
|
||||
<el-input
|
||||
v-model="tokenDraft"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="后台访问令牌"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveToken"
|
||||
/>
|
||||
<el-tooltip content="保存令牌" placement="bottom">
|
||||
<el-button :icon="Save" type="primary" @click="saveToken" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" aria-label="保洁概览">
|
||||
<div class="metric">
|
||||
<span>待验收</span>
|
||||
<strong>{{ statistics.summary.pendingReview }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>已完成</span>
|
||||
<strong>{{ statistics.summary.completed }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>待发放</span>
|
||||
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>待结算</span>
|
||||
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="action-board" aria-label="运营待处理">
|
||||
<button class="action-tile" type="button" @click="jumpToTaskStatus('SUBMITTED')">
|
||||
<ClipboardCheck :size="20" />
|
||||
<span>待验收任务</span>
|
||||
<strong>{{ statistics.summary.pendingReview }}</strong>
|
||||
</button>
|
||||
<button class="action-tile" type="button" @click="jumpToTaskStatus('REJECTED')">
|
||||
<RotateCcw :size="20" />
|
||||
<span>驳回补做</span>
|
||||
<strong>{{ statistics.summary.rejected }}</strong>
|
||||
</button>
|
||||
<button class="action-tile" type="button" @click="jumpToSettlementStatus('DRAFT')">
|
||||
<ListTodo :size="20" />
|
||||
<span>待确认结算</span>
|
||||
<strong>{{ draftSettlementTotal }}</strong>
|
||||
</button>
|
||||
<button class="action-tile" type="button" @click="jumpToSettlementStatus('CONFIRMED')">
|
||||
<Send :size="20" />
|
||||
<span>待发放金额</span>
|
||||
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
|
||||
</button>
|
||||
<button class="action-tile" type="button" @click="exportOperationalQueue">
|
||||
<Download :size="20" />
|
||||
<span>导出待处理</span>
|
||||
<strong>{{ operationalQueueTotal }}</strong>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="lastError"
|
||||
class="alert-line"
|
||||
:title="lastError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="lastError = ''"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="lastMessage"
|
||||
class="alert-line"
|
||||
:title="lastMessage"
|
||||
type="success"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="lastMessage = ''"
|
||||
/>
|
||||
|
||||
<el-tabs v-model="activeTab" class="work-tabs">
|
||||
<el-tab-pane label="任务" name="tasks">
|
||||
<CleaningTasksPanel
|
||||
:session="session"
|
||||
:loading="loading.tasks"
|
||||
:items="tasks.items"
|
||||
:cleaners="cleaners.items"
|
||||
:total="tasks.total"
|
||||
:page="tasks.page"
|
||||
:page-size="tasks.pageSize"
|
||||
:status="taskStatus"
|
||||
:filters="taskFilters"
|
||||
@refresh="loadTasks"
|
||||
@status-change="changeTaskStatus"
|
||||
@filter="changeTaskFilters"
|
||||
@page-change="changeTaskPage"
|
||||
@assign="handleAssign"
|
||||
@complete="handleComplete"
|
||||
@complete-many="handleCompleteMany"
|
||||
@reject="handleReject"
|
||||
@reject-many="handleRejectMany"
|
||||
@exempt="handleExempt"
|
||||
@exempt-many="handleExemptMany"
|
||||
@reclaim="handleReclaim"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="结算" name="settlements">
|
||||
<CleaningSettlementsPanel
|
||||
:session="session"
|
||||
:loading="loading.settlements"
|
||||
:items="settlements.items"
|
||||
:cleaners="cleaners.items"
|
||||
:total="settlements.total"
|
||||
:page="settlements.page"
|
||||
:page-size="settlements.pageSize"
|
||||
:status="settlementStatus"
|
||||
:filters="settlementFilters"
|
||||
@refresh="loadSettlements"
|
||||
@status-change="changeSettlementStatus"
|
||||
@filter="changeSettlementFilters"
|
||||
@page-change="changeSettlementPage"
|
||||
@generate="handleGenerateSettlement"
|
||||
@confirm="handleConfirmSettlement"
|
||||
@transfer="handleTransfer"
|
||||
@sync-transfer="handleSyncTransfer"
|
||||
@sync-transfer-many="handleSyncTransferMany"
|
||||
@failure="handleFailure"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="统计" name="statistics">
|
||||
<CleaningStatisticsPanel
|
||||
:loading="loading.statistics"
|
||||
:statistics="statistics"
|
||||
:filters="statisticsFilters"
|
||||
:cleaners="cleaners.items"
|
||||
@refresh="loadStatistics"
|
||||
@filter="changeStatisticsFilters"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="保洁员" name="cleaners">
|
||||
<CleanersPanel
|
||||
:loading="loading.cleaners"
|
||||
:items="cleaners.items"
|
||||
:total="cleaners.total"
|
||||
:page="cleaners.page"
|
||||
:page-size="cleaners.pageSize"
|
||||
:status="cleanerStatus"
|
||||
:search="cleanerSearch"
|
||||
@refresh="loadCleaners"
|
||||
@status-change="changeCleanerStatus"
|
||||
@search-change="changeCleanerSearch"
|
||||
@page-change="changeCleanerPage"
|
||||
@create="handleCreateCleaner"
|
||||
@update="handleUpdateCleaner"
|
||||
@status-toggle="handleToggleCleanerStatus"
|
||||
@reset-sessions="handleResetCleanerSessions"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="联调" name="handoff">
|
||||
<CleaningFieldHandoffPanel />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</main>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Download,
|
||||
ListTodo,
|
||||
RadioTower,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Send,
|
||||
Sparkles,
|
||||
Store,
|
||||
WalletCards
|
||||
} from '@lucide/vue';
|
||||
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
|
||||
import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue';
|
||||
import CleaningStatisticsPanel from './components/CleaningStatisticsPanel.vue';
|
||||
import CleanersPanel from './components/CleanersPanel.vue';
|
||||
import CleaningFieldHandoffPanel from './components/CleaningFieldHandoffPanel.vue';
|
||||
import {
|
||||
ApiError,
|
||||
assignCleaningTask,
|
||||
completeCleaningTask,
|
||||
confirmCleaningSettlement,
|
||||
createStaffUser,
|
||||
exemptCleaningTask,
|
||||
executeWechatTransfer,
|
||||
generateCleaningSettlement,
|
||||
getCleaningStatistics,
|
||||
listCleaningSettlements,
|
||||
listCleaningTasks,
|
||||
listStaffUsers,
|
||||
reclaimCleaningTimeouts,
|
||||
recordPayoutFailure,
|
||||
rejectCleaningTask,
|
||||
resetStaffSessions,
|
||||
syncWechatTransfer,
|
||||
updateStaffUser
|
||||
} from './api';
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningStatistics,
|
||||
CleaningTask,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
PayoutStateFilter,
|
||||
SettlementStatus,
|
||||
TaskStatus,
|
||||
TransferMode,
|
||||
UserStatus
|
||||
} from './types';
|
||||
import { money } from './format';
|
||||
|
||||
const tokenDraft = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||
const activeTab = ref('tasks');
|
||||
const lastError = ref('');
|
||||
const lastMessage = ref('');
|
||||
const taskStatus = ref<TaskStatus | ''>('SUBMITTED');
|
||||
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
|
||||
const cleanerStatus = ref<UserStatus | ''>('ACTIVE');
|
||||
const cleanerSearch = ref('');
|
||||
const session = computed(() => ({ token: tokenDraft.value }));
|
||||
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
|
||||
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const settlements = reactive<PageResult<CleaningSettlement>>({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
const cleaners = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const taskFilters = reactive({ storeId: '', cleanerUserId: '' });
|
||||
const settlementFilters = reactive<{ storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' }>({
|
||||
storeId: '',
|
||||
cleanerUserId: '',
|
||||
payoutState: ''
|
||||
});
|
||||
const statisticsFilters = reactive({ from: '', to: '', storeId: '', cleanerUserId: '' });
|
||||
const statistics = reactive<CleaningStatistics>({
|
||||
summary: {
|
||||
taskTotal: 0,
|
||||
pendingReview: 0,
|
||||
active: 0,
|
||||
rejected: 0,
|
||||
exempted: 0,
|
||||
completed: 0,
|
||||
rewardCents: 0,
|
||||
pendingSettlementCents: 0,
|
||||
confirmedSettlementCents: 0,
|
||||
paidSettlementCents: 0
|
||||
},
|
||||
byStatus: [],
|
||||
byStore: [],
|
||||
settlements: [],
|
||||
members: [],
|
||||
trend: []
|
||||
});
|
||||
const draftSettlementTotal = computed(() => statistics.settlements
|
||||
.find((item) => item.status === 'DRAFT')?.total || 0);
|
||||
const operationalQueueTotal = computed(() => (
|
||||
statistics.summary.pendingReview
|
||||
+ statistics.summary.rejected
|
||||
+ draftSettlementTotal.value
|
||||
));
|
||||
|
||||
function saveToken() {
|
||||
localStorage.setItem('qipai.admin.token', tokenDraft.value.trim());
|
||||
ElMessage.success('已保存');
|
||||
}
|
||||
|
||||
async function runAction(work: () => Promise<void>, success: string) {
|
||||
lastError.value = '';
|
||||
lastMessage.value = '';
|
||||
try {
|
||||
await work();
|
||||
lastMessage.value = success;
|
||||
ElMessage.success(success);
|
||||
} catch (error) {
|
||||
lastError.value = error instanceof ApiError
|
||||
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
|
||||
: error instanceof Error ? error.message : '操作失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
loading.tasks = true;
|
||||
await runAction(async () => {
|
||||
const result = await listCleaningTasks(session.value, {
|
||||
page: tasks.page,
|
||||
pageSize: tasks.pageSize,
|
||||
status: taskStatus.value || undefined,
|
||||
storeId: taskFilters.storeId || undefined,
|
||||
cleanerUserId: taskFilters.cleanerUserId || undefined
|
||||
});
|
||||
Object.assign(tasks, result);
|
||||
}, '任务已刷新');
|
||||
loading.tasks = false;
|
||||
}
|
||||
|
||||
async function loadSettlements() {
|
||||
loading.settlements = true;
|
||||
await runAction(async () => {
|
||||
const result = await listCleaningSettlements(session.value, {
|
||||
page: settlements.page,
|
||||
pageSize: settlements.pageSize,
|
||||
status: settlementStatus.value || undefined,
|
||||
payoutState: settlementFilters.payoutState || undefined,
|
||||
storeId: settlementFilters.storeId || undefined,
|
||||
cleanerUserId: settlementFilters.cleanerUserId || undefined
|
||||
});
|
||||
Object.assign(settlements, result);
|
||||
}, '结算已刷新');
|
||||
loading.settlements = false;
|
||||
}
|
||||
|
||||
async function loadStatistics() {
|
||||
loading.statistics = true;
|
||||
await runAction(async () => {
|
||||
const result = await getCleaningStatistics(session.value, {
|
||||
from: statisticsFilters.from || undefined,
|
||||
to: statisticsFilters.to || undefined,
|
||||
storeId: statisticsFilters.storeId || undefined,
|
||||
cleanerUserId: statisticsFilters.cleanerUserId || undefined
|
||||
});
|
||||
Object.assign(statistics.summary, result.summary);
|
||||
statistics.byStatus = result.byStatus;
|
||||
statistics.byStore = result.byStore;
|
||||
statistics.settlements = result.settlements;
|
||||
statistics.members = result.members;
|
||||
statistics.trend = result.trend;
|
||||
}, '统计已刷新');
|
||||
loading.statistics = false;
|
||||
}
|
||||
|
||||
async function loadCleaners() {
|
||||
loading.cleaners = true;
|
||||
await runAction(async () => {
|
||||
const result = await listStaffUsers(session.value, {
|
||||
page: cleaners.page,
|
||||
pageSize: cleaners.pageSize,
|
||||
role: 'CLEANER',
|
||||
status: cleanerStatus.value || undefined,
|
||||
search: cleanerSearch.value || undefined
|
||||
});
|
||||
Object.assign(cleaners, {
|
||||
items: result.items,
|
||||
total: result.total,
|
||||
page: cleaners.page,
|
||||
pageSize: cleaners.pageSize
|
||||
});
|
||||
}, '保洁员已刷新');
|
||||
loading.cleaners = false;
|
||||
}
|
||||
|
||||
function changeTaskStatus(status: TaskStatus | '') {
|
||||
taskStatus.value = status;
|
||||
tasks.page = 1;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function jumpToTaskStatus(status: TaskStatus) {
|
||||
activeTab.value = 'tasks';
|
||||
taskStatus.value = status;
|
||||
tasks.page = 1;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function changeTaskFilters(input: { storeId: string; cleanerUserId: string }) {
|
||||
Object.assign(taskFilters, input);
|
||||
tasks.page = 1;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function changeSettlementStatus(status: SettlementStatus | '') {
|
||||
settlementStatus.value = status;
|
||||
settlements.page = 1;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function jumpToSettlementStatus(status: SettlementStatus) {
|
||||
activeTab.value = 'settlements';
|
||||
settlementStatus.value = status;
|
||||
settlementFilters.payoutState = '';
|
||||
settlements.page = 1;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function exportOperationalQueue() {
|
||||
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
|
||||
['队列', '数量', '金额', '入口'],
|
||||
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
|
||||
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
|
||||
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
|
||||
['待发放金额', '', money(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
|
||||
['待结算金额', '', money(statistics.summary.pendingSettlementCents), '统计/待结算'],
|
||||
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
|
||||
]);
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: string[][]) {
|
||||
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvCell(value: string) {
|
||||
const normalized = value.replace(/\r?\n/g, ' ');
|
||||
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
|
||||
}
|
||||
|
||||
function changeSettlementFilters(input: { storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' }) {
|
||||
Object.assign(settlementFilters, input);
|
||||
settlements.page = 1;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function changeTaskPage(page: number) {
|
||||
tasks.page = page;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function changeSettlementPage(page: number) {
|
||||
settlements.page = page;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function changeStatisticsFilters(input: { from: string; to: string; storeId: string; cleanerUserId: string }) {
|
||||
Object.assign(statisticsFilters, input);
|
||||
void loadStatistics();
|
||||
}
|
||||
|
||||
function changeCleanerStatus(status: UserStatus | '') {
|
||||
cleanerStatus.value = status;
|
||||
cleaners.page = 1;
|
||||
void loadCleaners();
|
||||
}
|
||||
|
||||
function changeCleanerSearch(search: string) {
|
||||
cleanerSearch.value = search;
|
||||
cleaners.page = 1;
|
||||
void loadCleaners();
|
||||
}
|
||||
|
||||
function changeCleanerPage(page: number) {
|
||||
cleaners.page = page;
|
||||
void loadCleaners();
|
||||
}
|
||||
|
||||
async function handleAssign(input: { taskId: string; cleanerUserId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await assignCleaningTask(session.value, input.taskId, input);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已指派');
|
||||
}
|
||||
|
||||
async function handleComplete(input: { taskId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await completeCleaningTask(session.value, input.taskId, input.note);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已验收');
|
||||
}
|
||||
|
||||
async function handleCompleteMany(input: { taskIds: string[]; note?: string }) {
|
||||
await runAction(async () => {
|
||||
for (const taskId of input.taskIds) {
|
||||
await completeCleaningTask(session.value, taskId, input.note);
|
||||
}
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, `已批量验收 ${input.taskIds.length} 个任务`);
|
||||
}
|
||||
|
||||
async function handleReject(input: { taskId: string; reason: string }) {
|
||||
await runAction(async () => {
|
||||
await rejectCleaningTask(session.value, input.taskId, input.reason);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已驳回');
|
||||
}
|
||||
|
||||
async function handleRejectMany(input: { taskIds: string[]; reason: string }) {
|
||||
await runAction(async () => {
|
||||
for (const taskId of input.taskIds) {
|
||||
await rejectCleaningTask(session.value, taskId, input.reason);
|
||||
}
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, `已批量驳回 ${input.taskIds.length} 个任务`);
|
||||
}
|
||||
|
||||
async function handleExempt(input: { taskId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await exemptCleaningTask(session.value, input.taskId, input.note);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '宸叉爣璁板厤娓呮磥');
|
||||
}
|
||||
|
||||
async function handleExemptMany(input: { taskIds: string[]; note?: string }) {
|
||||
await runAction(async () => {
|
||||
for (const taskId of input.taskIds) {
|
||||
await exemptCleaningTask(session.value, taskId, input.note);
|
||||
}
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, `\u5df2\u6279\u91cf\u6807\u8bb0\u514d\u6e05\u6d01 ${input.taskIds.length} \u4e2a\u4efb\u52a1`);
|
||||
}
|
||||
|
||||
async function handleReclaim(input: { olderThanMinutes: number; limit: number }) {
|
||||
await runAction(async () => {
|
||||
await reclaimCleaningTimeouts(session.value, input);
|
||||
await Promise.all([loadTasks(), loadStatistics()]);
|
||||
}, '已回收超时任务');
|
||||
}
|
||||
|
||||
async function handleGenerateSettlement(
|
||||
input: { cleanerUserId: string; storeId?: string; note?: string }
|
||||
) {
|
||||
await runAction(async () => {
|
||||
await generateCleaningSettlement(session.value, input);
|
||||
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
|
||||
}, '已生成结算单');
|
||||
}
|
||||
|
||||
async function handleConfirmSettlement(input: { settlementId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await confirmCleaningSettlement(session.value, input.settlementId, input.note);
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, '已确认结算单');
|
||||
}
|
||||
|
||||
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await executeWechatTransfer(session.value, input.settlementId, {
|
||||
mode: input.mode,
|
||||
note: input.note
|
||||
});
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, '已提交微信转账');
|
||||
}
|
||||
|
||||
async function handleSyncTransfer(input: { settlementId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await syncWechatTransfer(session.value, input.settlementId, input.note);
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, '已同步微信状态');
|
||||
}
|
||||
|
||||
async function handleSyncTransferMany(input: { settlementIds: string[]; note?: string }) {
|
||||
await runAction(async () => {
|
||||
for (const settlementId of input.settlementIds) {
|
||||
await syncWechatTransfer(session.value, settlementId, input.note);
|
||||
}
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, `已批量同步 ${input.settlementIds.length} 个结算单`);
|
||||
}
|
||||
|
||||
async function handleFailure(
|
||||
input: { settlementId: string; error: string; payoutReference?: string; note?: string }
|
||||
) {
|
||||
await runAction(async () => {
|
||||
await recordPayoutFailure(session.value, input.settlementId, {
|
||||
error: input.error,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: input.payoutReference,
|
||||
note: input.note
|
||||
});
|
||||
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||
}, '已记录失败');
|
||||
}
|
||||
|
||||
async function handleCreateCleaner(
|
||||
input: { nickname: string; phone: string; storeIds: string[]; note?: string }
|
||||
) {
|
||||
await runAction(async () => {
|
||||
await createStaffUser(session.value, {
|
||||
nickname: input.nickname,
|
||||
phone: input.phone,
|
||||
roles: ['CLEANER'],
|
||||
storeIds: input.storeIds,
|
||||
note: input.note
|
||||
});
|
||||
await loadCleaners();
|
||||
}, '已新增保洁员');
|
||||
}
|
||||
|
||||
async function handleUpdateCleaner(
|
||||
input: { userId: string; nickname: string; phone?: string; storeIds: string[]; note?: string }
|
||||
) {
|
||||
await runAction(async () => {
|
||||
await updateStaffUser(session.value, input.userId, {
|
||||
nickname: input.nickname,
|
||||
phone: input.phone,
|
||||
roles: ['CLEANER'],
|
||||
storeIds: input.storeIds,
|
||||
note: input.note
|
||||
});
|
||||
await loadCleaners();
|
||||
}, '已更新保洁员');
|
||||
}
|
||||
|
||||
async function handleToggleCleanerStatus(input: { userId: string; status: UserStatus }) {
|
||||
await runAction(async () => {
|
||||
await updateStaffUser(session.value, input.userId, { status: input.status });
|
||||
await loadCleaners();
|
||||
}, input.status === 'ACTIVE' ? '已启用保洁员' : '已停用保洁员');
|
||||
}
|
||||
|
||||
async function handleResetCleanerSessions(userId: string) {
|
||||
await runAction(async () => {
|
||||
await resetStaffSessions(session.value, userId);
|
||||
await loadCleaners();
|
||||
}, '已重置保洁员会话');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (session.value.token) {
|
||||
void Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,339 @@
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningSettlementDetail,
|
||||
CleaningStatistics,
|
||||
CleaningTask,
|
||||
CleaningTaskEvent,
|
||||
CleaningTaskMember,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
PayoutStateFilter,
|
||||
StaffRole,
|
||||
SettlementStatus,
|
||||
TaskStatus,
|
||||
TransferMode,
|
||||
UserStatus,
|
||||
WechatTransferPreflight
|
||||
} from './types';
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_ADMIN_API_BASE_URL || '/admin-api').replace(/\/$/, '');
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message = code,
|
||||
public readonly traceId = ''
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiSession {
|
||||
token: string;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
session: ApiSession,
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
if (!session.token.trim()) {
|
||||
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
|
||||
}
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('authorization', `Bearer ${session.token.trim()}`);
|
||||
if (options.body && !headers.has('content-type')) {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
throw new ApiError(
|
||||
String(payload.code || `HTTP_${response.status}`),
|
||||
String(payload.message || '请求失败'),
|
||||
String(payload.traceId || '')
|
||||
);
|
||||
}
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
export function listCleaningTasks(
|
||||
session: ApiSession,
|
||||
input: { page: number; pageSize: number; status?: TaskStatus; storeId?: string; cleanerUserId?: string }
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(input.page),
|
||||
pageSize: String(input.pageSize)
|
||||
});
|
||||
if (input.status) params.set('status', input.status);
|
||||
if (input.storeId) params.set('storeId', input.storeId);
|
||||
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||
return request<PageResult<CleaningTask>>(session, `/cleaning/tasks?${params}`);
|
||||
}
|
||||
|
||||
export function getCleaningStatistics(
|
||||
session: ApiSession,
|
||||
input: { from?: string; to?: string; storeId?: string; cleanerUserId?: string } = {}
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (input.from) params.set('from', input.from);
|
||||
if (input.to) params.set('to', input.to);
|
||||
if (input.storeId) params.set('storeId', input.storeId);
|
||||
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||
const query = params.toString();
|
||||
return request<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export function listStaffUsers(
|
||||
session: ApiSession,
|
||||
input: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
role?: StaffRole;
|
||||
status?: UserStatus;
|
||||
search?: string;
|
||||
}
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(input.page),
|
||||
pageSize: String(input.pageSize)
|
||||
});
|
||||
if (input.role) params.set('role', input.role);
|
||||
if (input.status) params.set('status', input.status);
|
||||
if (input.search) params.set('search', input.search);
|
||||
return request<{ items: ManagedUser[]; total: number }>(session, `/users?${params}`);
|
||||
}
|
||||
|
||||
export function createStaffUser(
|
||||
session: ApiSession,
|
||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||
) {
|
||||
return request<{ userId: string }>(session, '/staff', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateStaffUser(
|
||||
session: ApiSession,
|
||||
userId: string,
|
||||
input: Partial<{
|
||||
nickname: string;
|
||||
phone: string;
|
||||
note: string;
|
||||
status: UserStatus;
|
||||
roles: StaffRole[];
|
||||
storeIds: string[];
|
||||
}>
|
||||
) {
|
||||
return request<{ userId: string }>(session, `/users/${encodeURIComponent(userId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function resetStaffSessions(session: ApiSession, userId: string) {
|
||||
return request<{ userId: string; revokedSessions: number }>(
|
||||
session,
|
||||
`/users/${encodeURIComponent(userId)}/reset-sessions`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
export function assignCleaningTask(
|
||||
session: ApiSession,
|
||||
taskId: string,
|
||||
input: { cleanerUserId: string; note?: string }
|
||||
) {
|
||||
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function completeCleaningTask(session: ApiSession, taskId: string, note?: string) {
|
||||
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectCleaningTask(session: ApiSession, taskId: string, reason: string) {
|
||||
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
}
|
||||
|
||||
export function exemptCleaningTask(session: ApiSession, taskId: string, note?: string) {
|
||||
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/exempt`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
});
|
||||
}
|
||||
|
||||
export function listCleaningTaskMembers(session: ApiSession, taskId: string) {
|
||||
return request<CleaningTaskMember[]>(
|
||||
session,
|
||||
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`
|
||||
);
|
||||
}
|
||||
|
||||
export function listCleaningTaskEvents(session: ApiSession, taskId: string) {
|
||||
return request<CleaningTaskEvent[]>(
|
||||
session,
|
||||
`/cleaning/tasks/${encodeURIComponent(taskId)}/events`
|
||||
);
|
||||
}
|
||||
|
||||
export function addCleaningTaskMember(
|
||||
session: ApiSession,
|
||||
taskId: string,
|
||||
input: { cleanerUserId: string; rewardCents: number; note?: string }
|
||||
) {
|
||||
return request<CleaningTaskMember[]>(
|
||||
session,
|
||||
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function removeCleaningTaskMember(
|
||||
session: ApiSession,
|
||||
taskId: string,
|
||||
cleanerUserId: string,
|
||||
note?: string
|
||||
) {
|
||||
return request<CleaningTaskMember[]>(
|
||||
session,
|
||||
`/cleaning/tasks/${encodeURIComponent(taskId)}/members/${encodeURIComponent(cleanerUserId)}/remove`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function reclaimCleaningTimeouts(
|
||||
session: ApiSession,
|
||||
input: { olderThanMinutes: number; limit: number }
|
||||
) {
|
||||
return request<{ reclaimed: number; taskIds: string[] }>(session, '/cleaning/reclaim-timeouts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listCleaningSettlements(
|
||||
session: ApiSession,
|
||||
input: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: SettlementStatus;
|
||||
payoutState?: PayoutStateFilter;
|
||||
storeId?: string;
|
||||
cleanerUserId?: string;
|
||||
}
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(input.page),
|
||||
pageSize: String(input.pageSize)
|
||||
});
|
||||
if (input.status) params.set('status', input.status);
|
||||
if (input.payoutState) params.set('payoutState', input.payoutState);
|
||||
if (input.storeId) params.set('storeId', input.storeId);
|
||||
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
|
||||
}
|
||||
|
||||
export function listCleaningSettlementCandidates(
|
||||
session: ApiSession,
|
||||
input: { page: number; pageSize: number; storeId?: string; cleanerUserId?: string }
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(input.page),
|
||||
pageSize: String(input.pageSize)
|
||||
});
|
||||
if (input.storeId) params.set('storeId', input.storeId);
|
||||
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||
return request<PageResult<CleaningTask>>(session, `/cleaning/settlement-candidates?${params}`);
|
||||
}
|
||||
|
||||
export function generateCleaningSettlement(
|
||||
session: ApiSession,
|
||||
input: { cleanerUserId: string; storeId?: string; note?: string }
|
||||
) {
|
||||
return request<CleaningSettlement>(session, '/cleaning/settlements', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function getCleaningSettlementDetail(session: ApiSession, settlementId: string) {
|
||||
return request<CleaningSettlementDetail>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmCleaningSettlement(session: ApiSession, settlementId: string, note?: string) {
|
||||
return request<CleaningSettlement>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/confirm`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function executeWechatTransfer(
|
||||
session: ApiSession,
|
||||
settlementId: string,
|
||||
input: { mode: TransferMode; note?: string }
|
||||
) {
|
||||
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function preflightWechatTransfer(session: ApiSession, settlementId: string) {
|
||||
return request<WechatTransferPreflight>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer/preflight`
|
||||
);
|
||||
}
|
||||
|
||||
export function syncWechatTransfer(session: ApiSession, settlementId: string, note?: string) {
|
||||
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer/sync`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function recordPayoutFailure(
|
||||
session: ApiSession,
|
||||
settlementId: string,
|
||||
input: { error: string; payoutChannel?: string; payoutReference?: string; note?: string }
|
||||
) {
|
||||
return request<CleaningSettlement>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/payout-failure`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-actions">
|
||||
<el-input
|
||||
v-model="searchDraft"
|
||||
class="search-input"
|
||||
placeholder="姓名/手机号/ID"
|
||||
clearable
|
||||
@keyup.enter="emitSearch"
|
||||
/>
|
||||
<el-segmented
|
||||
:model-value="status"
|
||||
:options="statusOptions"
|
||||
@update:model-value="$emit('status-change', $event as UserStatus | '')"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="Download" :disabled="items.length === 0" @click="exportCleaners">
|
||||
导出
|
||||
</el-button>
|
||||
<el-button :icon="FileWarning" :disabled="incompleteCleaners.length === 0" @click="exportCleanerProfileIssues">
|
||||
导出待补
|
||||
</el-button>
|
||||
<el-button type="primary" :icon="UserPlus" @click="createDialog.open = true">
|
||||
新增
|
||||
</el-button>
|
||||
<el-tooltip content="刷新保洁员">
|
||||
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" :loading="loading" class="data-table" row-key="id">
|
||||
<el-table-column prop="nickname" label="保洁员" min-width="160" fixed>
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.nickname || compactText(row.id) }}</strong>
|
||||
<span>ID {{ row.id }} · {{ row.maskedPhone }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资料" width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="profile-badges">
|
||||
<el-tag :type="row.wechatMiniappBound ? 'success' : 'warning'" effect="light">
|
||||
{{ row.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
|
||||
</el-tag>
|
||||
<el-tag :type="profileComplete(row) ? 'success' : 'info'" effect="plain">
|
||||
{{ profileComplete(row) ? '资料完整' : '待补资料' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="门店范围" min-width="150">
|
||||
<template #default="{ row }">{{ row.storeIds.join(', ') || '未配置' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" label="备注" min-width="180">
|
||||
<template #default="{ row }">{{ compactText(row.note) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastLoginAt" label="最近登录" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="maskedLastIp" label="最近 IP" width="120" />
|
||||
<el-table-column label="操作" width="310" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||
:icon="row.status === 'ACTIVE' ? UserX : UserCheck"
|
||||
@click="$emit('status-toggle', { userId: row.id, status: row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE' })"
|
||||
>
|
||||
{{ row.status === 'ACTIVE' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" :icon="ShieldX" @click="$emit('reset-sessions', row.id)">
|
||||
会话
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
layout="prev, pager, next, total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="$emit('page-change', $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createDialog.open" title="新增保洁员" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="createDialog.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="createDialog.phone" inputmode="tel" />
|
||||
</el-form-item>
|
||||
<el-form-item label="门店 ID,逗号分隔">
|
||||
<el-input v-model="createDialog.storeIdsText" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="createDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editDialog.open" title="编辑保洁员" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="editDialog.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="editDialog.phone" inputmode="tel" placeholder="留空则不修改" />
|
||||
</el-form-item>
|
||||
<el-form-item label="门店 ID,逗号分隔">
|
||||
<el-input v-model="editDialog.storeIdsText" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import {
|
||||
Download,
|
||||
FileWarning,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
ShieldX,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
UserX
|
||||
} from '@lucide/vue';
|
||||
import { compactText, shortDate } from '../format';
|
||||
import type { ManagedUser, PageResult, UserStatus } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean;
|
||||
items: PageResult<ManagedUser>['items'];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status: UserStatus | '';
|
||||
search: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
'status-change': [UserStatus | ''];
|
||||
'search-change': [string];
|
||||
'page-change': [number];
|
||||
create: [{ nickname: string; phone: string; storeIds: string[]; note?: string }];
|
||||
update: [{ userId: string; nickname: string; phone?: string; storeIds: string[]; note?: string }];
|
||||
'status-toggle': [{ userId: string; status: UserStatus }];
|
||||
'reset-sessions': [string];
|
||||
}>();
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 'ACTIVE' },
|
||||
{ label: '停用', value: 'DISABLED' }
|
||||
];
|
||||
const searchDraft = ref(props.search);
|
||||
const createDialog = reactive({
|
||||
open: false,
|
||||
nickname: '',
|
||||
phone: '',
|
||||
storeIdsText: '',
|
||||
note: ''
|
||||
});
|
||||
const editDialog = reactive({
|
||||
open: false,
|
||||
userId: '',
|
||||
nickname: '',
|
||||
phone: '',
|
||||
storeIdsText: '',
|
||||
note: ''
|
||||
});
|
||||
const incompleteCleaners = computed(() => props.items.filter((row) => !profileComplete(row)));
|
||||
|
||||
watch(
|
||||
() => props.search,
|
||||
(value) => { searchDraft.value = value; }
|
||||
);
|
||||
|
||||
function parseStoreIds(value: string) {
|
||||
return value.split(/[,\s,]+/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function emitSearch() {
|
||||
emit('search-change', searchDraft.value.trim());
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
emit('create', {
|
||||
nickname: createDialog.nickname,
|
||||
phone: createDialog.phone,
|
||||
storeIds: parseStoreIds(createDialog.storeIdsText),
|
||||
note: createDialog.note
|
||||
});
|
||||
Object.assign(createDialog, { open: false, nickname: '', phone: '', storeIdsText: '', note: '' });
|
||||
}
|
||||
|
||||
function openEdit(row: ManagedUser) {
|
||||
editDialog.open = true;
|
||||
editDialog.userId = row.id;
|
||||
editDialog.nickname = row.nickname;
|
||||
editDialog.phone = '';
|
||||
editDialog.storeIdsText = row.storeIds.join(', ');
|
||||
editDialog.note = row.note;
|
||||
}
|
||||
|
||||
function submitEdit() {
|
||||
emit('update', {
|
||||
userId: editDialog.userId,
|
||||
nickname: editDialog.nickname,
|
||||
phone: editDialog.phone.trim() || undefined,
|
||||
storeIds: parseStoreIds(editDialog.storeIdsText),
|
||||
note: editDialog.note
|
||||
});
|
||||
editDialog.open = false;
|
||||
}
|
||||
|
||||
function profileComplete(row: ManagedUser) {
|
||||
return row.wechatMiniappBound && row.storeIds.length > 0 && Boolean(row.nickname);
|
||||
}
|
||||
|
||||
function profileIssues(row: ManagedUser) {
|
||||
const issues: string[] = [];
|
||||
if (!row.wechatMiniappBound) issues.push('未绑定微信');
|
||||
if (!row.storeIds.length) issues.push('未配置门店范围');
|
||||
if (!row.nickname) issues.push('缺姓名');
|
||||
return issues;
|
||||
}
|
||||
|
||||
function exportCleaners() {
|
||||
downloadCsv(`cleaning-cleaners-${Date.now()}.csv`, [
|
||||
['ID', '姓名', '状态', '微信绑定', '资料完整度', '门店范围', '手机号', '最近登录', '最近 IP', '注册时间', '备注'],
|
||||
...props.items.map((row) => [
|
||||
row.id,
|
||||
row.nickname || '',
|
||||
row.status,
|
||||
row.wechatMiniappBound ? '已绑定微信' : '未绑定微信',
|
||||
profileComplete(row) ? '资料完整' : '待补资料',
|
||||
row.storeIds.join(' / '),
|
||||
row.maskedPhone,
|
||||
row.lastLoginAt || '',
|
||||
row.maskedLastIp,
|
||||
row.registeredAt,
|
||||
row.note
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function exportCleanerProfileIssues() {
|
||||
downloadCsv(`cleaning-cleaner-profile-issues-${Date.now()}.csv`, [
|
||||
['ID', '姓名', '状态', '待补项目', '微信绑定', '门店范围', '手机号', '最近登录', '备注'],
|
||||
...incompleteCleaners.value.map((row) => [
|
||||
row.id,
|
||||
row.nickname || '',
|
||||
row.status,
|
||||
profileIssues(row).join(' / '),
|
||||
row.wechatMiniappBound ? '已绑定微信' : '未绑定微信',
|
||||
row.storeIds.join(' / '),
|
||||
row.maskedPhone,
|
||||
row.lastLoginAt || '',
|
||||
row.note
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: string[][]) {
|
||||
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvCell(value: string) {
|
||||
return `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,868 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-actions">
|
||||
<el-segmented
|
||||
:model-value="status"
|
||||
:options="statusOptions"
|
||||
@update:model-value="$emit('status-change', $event as SettlementStatus | '')"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filterDraft.storeId"
|
||||
class="narrow-input"
|
||||
placeholder="门店 ID"
|
||||
clearable
|
||||
@keyup.enter="submitFilters"
|
||||
@clear="submitFilters"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterDraft.cleanerUserId"
|
||||
class="filter-select"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="保洁员"
|
||||
@change="submitFilters"
|
||||
@clear="submitFilters"
|
||||
>
|
||||
<el-option
|
||||
v-for="cleaner in cleaners"
|
||||
:key="cleaner.id"
|
||||
:label="cleanerLabel(cleaner)"
|
||||
:value="cleaner.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterDraft.payoutState"
|
||||
class="filter-select"
|
||||
clearable
|
||||
placeholder="转账状态"
|
||||
@change="submitFilters"
|
||||
@clear="submitFilters"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in payoutStateOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button :icon="Search" @click="submitFilters">筛选</el-button>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button
|
||||
:icon="RefreshCw"
|
||||
:disabled="selectedSyncableCount === 0"
|
||||
@click="submitBatchSync"
|
||||
>
|
||||
批量同步
|
||||
</el-button>
|
||||
<el-button :icon="Download" :disabled="items.length === 0" @click="exportSettlements">
|
||||
导出
|
||||
</el-button>
|
||||
<el-button :icon="ListChecks" @click="openCandidates">
|
||||
候选
|
||||
</el-button>
|
||||
<el-button :icon="FilePlus2" type="primary" @click="generateDialog.open = true">
|
||||
生成
|
||||
</el-button>
|
||||
<el-tooltip content="刷新结算">
|
||||
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
class="data-table"
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="44" :selectable="canSelectSettlement" />
|
||||
<el-table-column prop="settlementNo" label="结算单" min-width="190" fixed>
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.settlementNo }}</strong>
|
||||
<span>{{ row.cleanerName }} · {{ compactText(row.storeName) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="115">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="settlementTag(row.status)" effect="light">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="taskCount" label="任务" width="80" />
|
||||
<el-table-column prop="totalRewardCents" label="金额" width="110">
|
||||
<template #default="{ row }">{{ money(row.totalRewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payoutState" label="转账状态" width="130">
|
||||
<template #default="{ row }">{{ compactText(row.payoutState) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payoutReference" label="流水" min-width="150">
|
||||
<template #default="{ row }">{{ compactText(row.payoutReference) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payoutError" label="失败原因" min-width="150">
|
||||
<template #default="{ row }">{{ compactText(row.payoutError) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="BadgeCheck"
|
||||
:disabled="row.status !== 'DRAFT'"
|
||||
@click="$emit('confirm', { settlementId: row.id, note: '后台确认结算' })"
|
||||
>
|
||||
确认
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="ClipboardCheck"
|
||||
:disabled="row.status !== 'CONFIRMED'"
|
||||
@click="openPreflight(row)"
|
||||
>
|
||||
预检
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:icon="Send"
|
||||
:disabled="row.status !== 'CONFIRMED'"
|
||||
@click="openTransfer(row)"
|
||||
>
|
||||
转账
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="RefreshCw"
|
||||
:disabled="row.status !== 'CONFIRMED'"
|
||||
@click="$emit('sync-transfer', { settlementId: row.id, note: '后台同步微信转账状态' })"
|
||||
>
|
||||
同步
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
:icon="CircleAlert"
|
||||
:disabled="row.status !== 'CONFIRMED'"
|
||||
@click="openFailure(row)"
|
||||
>
|
||||
失败
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
layout="prev, pager, next, total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="$emit('page-change', $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="generateDialog.open" title="生成结算单" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="保洁员">
|
||||
<el-select
|
||||
v-model="generateDialog.cleanerUserId"
|
||||
class="cleaner-select"
|
||||
filterable
|
||||
placeholder="选择保洁员"
|
||||
:disabled="!cleaners.length"
|
||||
>
|
||||
<el-option
|
||||
v-for="cleaner in cleaners"
|
||||
:key="cleaner.id"
|
||||
:label="cleanerLabel(cleaner)"
|
||||
:value="cleaner.id"
|
||||
>
|
||||
<div class="option-row">
|
||||
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
||||
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
|
||||
</div>
|
||||
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
|
||||
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
|
||||
</el-tag>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="门店 ID">
|
||||
<el-input v-model="generateDialog.storeId" inputmode="numeric" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="generateDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="generateDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitGenerate">生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="candidateDialog.open" title="待结算候选任务" width="760px">
|
||||
<div class="dialog-stack">
|
||||
<div class="detail-grid">
|
||||
<span>候选任务<strong>{{ candidateDialog.total }}</strong></span>
|
||||
<span>当前页金额<strong>{{ money(candidatePageRewardCents) }}</strong></span>
|
||||
<span>门店筛选<strong>{{ compactText(filterDraft.storeId || '全部') }}</strong></span>
|
||||
<span>保洁员筛选<strong>{{ compactText(filterDraft.cleanerUserId || '全部') }}</strong></span>
|
||||
</div>
|
||||
<el-table :data="candidateDialog.items" size="small" v-loading="candidateDialog.loading">
|
||||
<el-table-column prop="taskNo" label="任务" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.taskNo }}</strong>
|
||||
<span>{{ compactText(row.orderNo) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.storeName }} · {{ row.roomName || row.roomNo }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cleanerUserId" label="保洁员" width="110">
|
||||
<template #default="{ row }">{{ compactText(row.cleanerUserId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rewardCents" label="金额" width="110">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completedAt" label="完成" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
layout="prev, pager, next, total"
|
||||
:current-page="candidateDialog.page"
|
||||
:page-size="candidateDialog.pageSize"
|
||||
:total="candidateDialog.total"
|
||||
@current-change="changeCandidatePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :icon="Download" :disabled="candidateDialog.items.length === 0" @click="exportCandidates">
|
||||
导出候选
|
||||
</el-button>
|
||||
<el-button @click="candidateDialog.open = false">关闭</el-button>
|
||||
<el-button type="primary" @click="useCandidateFilters">用当前筛选生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="transferDialog.open" title="微信转账" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="模式">
|
||||
<el-radio-group v-model="transferDialog.mode">
|
||||
<el-radio-button label="API">API</el-radio-button>
|
||||
<el-radio-button label="MOCK">MOCK</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
v-if="transferDialog.mode === 'API'"
|
||||
class="transfer-preflight-alert"
|
||||
:title="transferPreflightTitle"
|
||||
:type="transferDialog.preflight?.ready ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-table
|
||||
v-if="transferDialog.mode === 'API' && transferDialog.preflight"
|
||||
:data="transferDialog.preflight.checks"
|
||||
size="small"
|
||||
class="preflight-table"
|
||||
max-height="220"
|
||||
>
|
||||
<el-table-column prop="key" label="检查项" width="160" />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="preflightTag(row.status)" effect="light">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="说明" min-width="220" />
|
||||
</el-table>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="transferDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialog.open = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="transferDialog.preflightLoading"
|
||||
:disabled="!transferCanSubmit"
|
||||
@click="submitTransfer"
|
||||
>
|
||||
发起
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="preflightDialog.open" title="微信转账预检" width="560px">
|
||||
<div v-if="preflightDialog.result" class="preflight-summary">
|
||||
<el-alert
|
||||
:title="preflightDialog.result.ready ? '真实转账前置条件已通过' : '仍有阻断项需要处理'"
|
||||
:type="preflightDialog.result.ready ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<div class="preflight-meta">
|
||||
<span>结算单 {{ preflightDialog.result.settlement.settlementNo }}</span>
|
||||
<span>商户 {{ compactText(preflightDialog.result.account.merchantIdMasked) }}</span>
|
||||
<span>场景 {{ compactText(preflightDialog.result.credential.transferSceneId) }}</span>
|
||||
</div>
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item label="凭据引用">
|
||||
{{ compactText(preflightDialog.result.account.credentialRefMasked) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="授权状态">
|
||||
{{ compactText(preflightDialog.result.account.authorizationStatus) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="账户范围">
|
||||
{{ preflightDialog.result.account.storeScoped ? '门店' : '租户' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="AppID">
|
||||
{{ configuredText(preflightDialog.result.credential.appIdPresent) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="商户证书序列号">
|
||||
{{ configuredText(preflightDialog.result.credential.serialNoPresent) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="场景报备">
|
||||
{{ preflightDialog.result.credential.reportInfoCount ?? 0 }} 项
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="通知URL">
|
||||
{{ configuredText(preflightDialog.result.credential.transferNotifyUrlConfigured) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台证书">
|
||||
{{ preflightDialog.result.credential.platformCertificateCount ?? 0 }} 张
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="保洁员OpenID">
|
||||
{{ preflightDialog.result.cleaner.openidConfigured ? '已绑定' : '缺失' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="preflightDialog.result.checks" size="small" class="preflight-table">
|
||||
<el-table-column prop="key" label="检查项" width="170" />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="preflightTag(row.status)" effect="light">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="说明" min-width="220" />
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button
|
||||
:icon="Download"
|
||||
:disabled="!preflightDialog.result"
|
||||
@click="exportPreflight"
|
||||
>
|
||||
导出预检
|
||||
</el-button>
|
||||
<el-button @click="preflightDialog.open = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="failureDialog.open" title="记录失败" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="失败原因">
|
||||
<el-input v-model="failureDialog.error" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外部流水">
|
||||
<el-input v-model="failureDialog.payoutReference" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="failureDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="failureDialog.open = false">取消</el-button>
|
||||
<el-button type="danger" @click="submitFailure">记录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailDialog.open" title="结算单详情" width="720px">
|
||||
<div v-if="detailDialog.detail" class="dialog-stack">
|
||||
<div class="detail-grid">
|
||||
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
|
||||
<span>保洁员<strong>{{ detailDialog.detail.settlement.cleanerName }}</strong></span>
|
||||
<span>金额<strong>{{ money(detailDialog.detail.settlement.totalRewardCents) }}</strong></span>
|
||||
<span>状态<strong>{{ detailDialog.detail.settlement.status }}</strong></span>
|
||||
</div>
|
||||
<el-table :data="detailDialog.detail.items" size="small" v-loading="detailDialog.loading">
|
||||
<el-table-column prop="taskNo" label="任务" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.taskNo }}</strong>
|
||||
<span>{{ compactText(row.orderNo) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.storeName }} · {{ row.roomName || row.roomNo }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cleanerName" label="成员" width="130" />
|
||||
<el-table-column prop="rewardCents" label="金额" width="110">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completedAt" label="完成" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item label="转账状态">
|
||||
{{ compactText(detailDialog.detail.settlement.payoutState) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="外部流水">
|
||||
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="微信确认参数" :span="2">
|
||||
{{ compactText(detailDialog.detail.settlement.payoutPackageInfo) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="失败原因" :span="2">
|
||||
{{ compactText(detailDialog.detail.settlement.payoutError) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ compactText(detailDialog.detail.settlement.note) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :icon="Download" :disabled="!detailDialog.detail" @click="exportSettlementDetail">
|
||||
导出详情
|
||||
</el-button>
|
||||
<el-button @click="detailDialog.open = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import {
|
||||
BadgeCheck,
|
||||
CircleAlert,
|
||||
ClipboardCheck,
|
||||
Download,
|
||||
FilePlus2,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send
|
||||
} from '@lucide/vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
getCleaningSettlementDetail,
|
||||
listCleaningSettlementCandidates,
|
||||
preflightWechatTransfer
|
||||
} from '../api';
|
||||
import { compactText, money, shortDate } from '../format';
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningSettlementDetail,
|
||||
CleaningTask,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
PayoutStateFilter,
|
||||
SettlementStatus,
|
||||
TransferMode,
|
||||
WechatTransferPreflight
|
||||
} from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
session: { token: string };
|
||||
loading: boolean;
|
||||
items: PageResult<CleaningSettlement>['items'];
|
||||
cleaners: ManagedUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status: SettlementStatus | '';
|
||||
filters: { storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
'status-change': [SettlementStatus | ''];
|
||||
filter: [{ storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' }];
|
||||
'page-change': [number];
|
||||
generate: [{ cleanerUserId: string; storeId?: string; note?: string }];
|
||||
confirm: [{ settlementId: string; note?: string }];
|
||||
transfer: [{ settlementId: string; mode: TransferMode; note?: string }];
|
||||
'sync-transfer': [{ settlementId: string; note?: string }];
|
||||
'sync-transfer-many': [{ settlementIds: string[]; note?: string }];
|
||||
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
|
||||
}>();
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '待发', value: 'CONFIRMED' },
|
||||
{ label: '已发', value: 'PAID' },
|
||||
{ label: '取消', value: 'CANCELLED' }
|
||||
];
|
||||
const payoutStateOptions: Array<{ label: string; value: PayoutStateFilter }> = [
|
||||
{ label: '未发起', value: 'NONE' },
|
||||
{ label: '处理中', value: 'PROCESSING' },
|
||||
{ label: '待确认', value: 'WAIT_USER_CONFIRM' },
|
||||
{ label: '成功', value: 'SUCCESS' },
|
||||
{ label: '失败', value: 'FAIL' }
|
||||
];
|
||||
const filterDraft = reactive({
|
||||
storeId: props.filters.storeId,
|
||||
cleanerUserId: props.filters.cleanerUserId,
|
||||
payoutState: props.filters.payoutState
|
||||
});
|
||||
const selectedSettlements = ref<CleaningSettlement[]>([]);
|
||||
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
|
||||
const transferDialog = reactive({
|
||||
open: false,
|
||||
settlementId: '',
|
||||
mode: 'API' as TransferMode,
|
||||
note: '',
|
||||
preflightLoading: false,
|
||||
preflight: null as WechatTransferPreflight | null
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters,
|
||||
(value) => Object.assign(filterDraft, value),
|
||||
{ deep: true }
|
||||
);
|
||||
const preflightDialog = reactive<{
|
||||
open: boolean;
|
||||
result: WechatTransferPreflight | null;
|
||||
}>({
|
||||
open: false,
|
||||
result: null
|
||||
});
|
||||
const failureDialog = reactive({
|
||||
open: false,
|
||||
settlementId: '',
|
||||
error: '',
|
||||
payoutReference: '',
|
||||
note: ''
|
||||
});
|
||||
const detailDialog = reactive<{
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
detail: CleaningSettlementDetail | null;
|
||||
}>({
|
||||
open: false,
|
||||
loading: false,
|
||||
detail: null
|
||||
});
|
||||
const candidateDialog = reactive<{
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
items: CleaningTask[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}>({
|
||||
open: false,
|
||||
loading: false,
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
});
|
||||
const candidatePageRewardCents = computed(() => candidateDialog.items
|
||||
.reduce((sum, item) => sum + item.rewardCents, 0));
|
||||
const selectedSyncableSettlements = computed(() => selectedSettlements.value
|
||||
.filter((settlement) => settlement.status === 'CONFIRMED'));
|
||||
const selectedSyncableCount = computed(() => selectedSyncableSettlements.value.length);
|
||||
const transferCanSubmit = computed(() => {
|
||||
if (transferDialog.mode === 'MOCK') return true;
|
||||
return !transferDialog.preflightLoading && Boolean(transferDialog.preflight?.ready);
|
||||
});
|
||||
const transferPreflightTitle = computed(() => {
|
||||
if (transferDialog.preflightLoading) return '正在执行真实转账预检';
|
||||
if (!transferDialog.preflight) return '请等待预检完成后再发起真实转账';
|
||||
if (transferDialog.preflight.ready) return '真实转账前置条件已通过';
|
||||
const failedCount = transferDialog.preflight.checks
|
||||
.filter((check) => check.status === 'FAIL').length;
|
||||
return `仍有 ${failedCount} 个阻断项需要处理`;
|
||||
});
|
||||
|
||||
function settlementTag(status: SettlementStatus) {
|
||||
if (status === 'PAID') return 'success';
|
||||
if (status === 'CONFIRMED') return 'warning';
|
||||
if (status === 'CANCELLED') return 'info';
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
function preflightTag(status: string) {
|
||||
if (status === 'PASS') return 'success';
|
||||
if (status === 'WARN') return 'warning';
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
function configuredText(value?: boolean) {
|
||||
return value ? '已配置' : '缺失';
|
||||
}
|
||||
|
||||
function canSelectSettlement(row: CleaningSettlement) {
|
||||
return row.status === 'CONFIRMED';
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: CleaningSettlement[]) {
|
||||
selectedSettlements.value = rows;
|
||||
}
|
||||
|
||||
function cleanerLabel(cleaner: ManagedUser) {
|
||||
const name = cleaner.nickname || `ID ${cleaner.id}`;
|
||||
const phone = cleaner.maskedPhone ? ` · ${cleaner.maskedPhone}` : '';
|
||||
const stores = cleaner.storeIds.length ? ` · 门店 ${cleaner.storeIds.join('/')}` : '';
|
||||
return `${name}${phone}${stores}`;
|
||||
}
|
||||
|
||||
function submitFilters() {
|
||||
emit('filter', {
|
||||
storeId: filterDraft.storeId.trim(),
|
||||
cleanerUserId: filterDraft.cleanerUserId,
|
||||
payoutState: filterDraft.payoutState
|
||||
});
|
||||
}
|
||||
|
||||
function submitGenerate() {
|
||||
if (!generateDialog.cleanerUserId) {
|
||||
ElMessage.warning('请选择保洁员');
|
||||
return;
|
||||
}
|
||||
emit('generate', {
|
||||
cleanerUserId: generateDialog.cleanerUserId,
|
||||
storeId: generateDialog.storeId || undefined,
|
||||
note: generateDialog.note
|
||||
});
|
||||
generateDialog.open = false;
|
||||
}
|
||||
|
||||
async function openTransfer(row: CleaningSettlement) {
|
||||
transferDialog.open = true;
|
||||
transferDialog.settlementId = row.id;
|
||||
transferDialog.mode = 'API';
|
||||
transferDialog.note = '';
|
||||
transferDialog.preflight = null;
|
||||
transferDialog.preflightLoading = true;
|
||||
try {
|
||||
transferDialog.preflight = await preflightWechatTransfer(props.session, row.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '微信转账预检失败');
|
||||
} finally {
|
||||
transferDialog.preflightLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function submitTransfer() {
|
||||
emit('transfer', {
|
||||
settlementId: transferDialog.settlementId,
|
||||
mode: transferDialog.mode,
|
||||
note: transferDialog.note
|
||||
});
|
||||
transferDialog.open = false;
|
||||
}
|
||||
|
||||
function submitBatchSync() {
|
||||
const settlementIds = selectedSyncableSettlements.value.map((settlement) => settlement.id);
|
||||
if (!settlementIds.length) {
|
||||
ElMessage.warning('请选择待发放结算单');
|
||||
return;
|
||||
}
|
||||
emit('sync-transfer-many', {
|
||||
settlementIds,
|
||||
note: '后台批量同步微信转账状态'
|
||||
});
|
||||
}
|
||||
|
||||
async function openPreflight(row: CleaningSettlement) {
|
||||
try {
|
||||
preflightDialog.result = await preflightWechatTransfer(props.session, row.id);
|
||||
preflightDialog.open = true;
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '微信转账预检失败');
|
||||
}
|
||||
}
|
||||
|
||||
function openFailure(row: CleaningSettlement) {
|
||||
failureDialog.open = true;
|
||||
failureDialog.settlementId = row.id;
|
||||
failureDialog.error = row.payoutError || '';
|
||||
failureDialog.payoutReference = row.payoutReference || '';
|
||||
failureDialog.note = '';
|
||||
}
|
||||
|
||||
function submitFailure() {
|
||||
emit('failure', {
|
||||
settlementId: failureDialog.settlementId,
|
||||
error: failureDialog.error,
|
||||
payoutReference: failureDialog.payoutReference,
|
||||
note: failureDialog.note
|
||||
});
|
||||
failureDialog.open = false;
|
||||
}
|
||||
|
||||
async function openDetail(row: CleaningSettlement) {
|
||||
detailDialog.open = true;
|
||||
detailDialog.loading = true;
|
||||
try {
|
||||
detailDialog.detail = await getCleaningSettlementDetail(props.session, row.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '结算详情加载失败');
|
||||
} finally {
|
||||
detailDialog.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openCandidates() {
|
||||
candidateDialog.open = true;
|
||||
candidateDialog.page = 1;
|
||||
await loadCandidates();
|
||||
}
|
||||
|
||||
async function changeCandidatePage(page: number) {
|
||||
candidateDialog.page = page;
|
||||
await loadCandidates();
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
candidateDialog.loading = true;
|
||||
try {
|
||||
const result = await listCleaningSettlementCandidates(props.session, {
|
||||
page: candidateDialog.page,
|
||||
pageSize: candidateDialog.pageSize,
|
||||
storeId: filterDraft.storeId || undefined,
|
||||
cleanerUserId: filterDraft.cleanerUserId || undefined
|
||||
});
|
||||
candidateDialog.items = result.items;
|
||||
candidateDialog.total = result.total;
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '待结算候选加载失败');
|
||||
} finally {
|
||||
candidateDialog.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function useCandidateFilters() {
|
||||
generateDialog.open = true;
|
||||
generateDialog.storeId = filterDraft.storeId;
|
||||
generateDialog.cleanerUserId = filterDraft.cleanerUserId;
|
||||
candidateDialog.open = false;
|
||||
}
|
||||
|
||||
function exportSettlements() {
|
||||
downloadCsv(`cleaning-settlements-${Date.now()}.csv`, [
|
||||
['结算单', '状态', '保洁员', '门店', '任务数', '金额', '转账状态', '外部流水', '微信确认参数', '失败原因', '创建时间'],
|
||||
...props.items.map((item) => [
|
||||
item.settlementNo,
|
||||
item.status,
|
||||
item.cleanerName,
|
||||
item.storeName || '',
|
||||
String(item.taskCount),
|
||||
money(item.totalRewardCents),
|
||||
item.payoutState,
|
||||
item.payoutReference,
|
||||
item.payoutPackageInfo,
|
||||
item.payoutError,
|
||||
shortDate(item.createdAt)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function exportCandidates() {
|
||||
downloadCsv(`cleaning-settlement-candidates-${Date.now()}.csv`, [
|
||||
['任务号', '订单号', '门店', '房间', '保洁员ID', '金额', '完成时间'],
|
||||
...candidateDialog.items.map((item) => [
|
||||
item.taskNo,
|
||||
item.orderNo || '',
|
||||
item.storeName,
|
||||
item.roomName || item.roomNo,
|
||||
item.cleanerUserId || '',
|
||||
money(item.rewardCents),
|
||||
shortDate(item.completedAt)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function exportSettlementDetail() {
|
||||
const detail = detailDialog.detail;
|
||||
if (!detail) return;
|
||||
const settlement = detail.settlement;
|
||||
downloadCsv(`cleaning-settlement-detail-${settlement.settlementNo}-${Date.now()}.csv`, [
|
||||
['类别', '字段', '值'],
|
||||
['settlement', '结算单号', settlement.settlementNo],
|
||||
['settlement', '状态', settlement.status],
|
||||
['settlement', '保洁员', settlement.cleanerName],
|
||||
['settlement', '门店', settlement.storeName || ''],
|
||||
['settlement', '任务数', String(settlement.taskCount)],
|
||||
['settlement', '金额', money(settlement.totalRewardCents)],
|
||||
['settlement', '转账状态', settlement.payoutState],
|
||||
['settlement', '外部流水', settlement.payoutReference],
|
||||
['settlement', '微信确认参数', settlement.payoutPackageInfo],
|
||||
['settlement', '失败原因', settlement.payoutError],
|
||||
['settlement', '备注', settlement.note],
|
||||
['settlement', '创建时间', shortDate(settlement.createdAt)],
|
||||
...detail.items.map((item) => [
|
||||
'item',
|
||||
item.taskNo,
|
||||
`${item.storeName} ${item.roomName || item.roomNo} ${item.cleanerName} ${money(item.rewardCents)} ${shortDate(item.completedAt)}`
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function exportPreflight() {
|
||||
const result = preflightDialog.result;
|
||||
if (!result) return;
|
||||
downloadCsv(`cleaning-transfer-preflight-${result.settlement.settlementNo}-${Date.now()}.csv`, [
|
||||
['类别', '字段', '值'],
|
||||
['summary', 'ready', result.ready ? 'PASS' : 'BLOCKED'],
|
||||
['settlement', 'settlementNo', result.settlement.settlementNo],
|
||||
['settlement', 'status', result.settlement.status],
|
||||
['settlement', 'amount', money(result.settlement.totalRewardCents)],
|
||||
['settlement', 'cleanerUserId', result.settlement.cleanerUserId],
|
||||
['settlement', 'storeId', result.settlement.storeId || ''],
|
||||
['account', 'merchantId', compactText(result.account.merchantIdMasked)],
|
||||
['account', 'credentialRef', compactText(result.account.credentialRefMasked)],
|
||||
['account', 'authorizationStatus', compactText(result.account.authorizationStatus)],
|
||||
['account', 'scope', result.account.storeScoped ? 'STORE' : 'TENANT'],
|
||||
['credential', 'appId', configuredText(result.credential.appIdPresent)],
|
||||
['credential', 'serialNo', configuredText(result.credential.serialNoPresent)],
|
||||
['credential', 'transferSceneId', compactText(result.credential.transferSceneId)],
|
||||
['credential', 'reportInfoCount', String(result.credential.reportInfoCount ?? 0)],
|
||||
['credential', 'transferNotifyUrl', configuredText(result.credential.transferNotifyUrlConfigured)],
|
||||
['credential', 'platformCertificateCount', String(result.credential.platformCertificateCount ?? 0)],
|
||||
['cleaner', 'openid', result.cleaner.openidConfigured ? 'BOUND' : 'MISSING'],
|
||||
...result.checks.map((check) => [
|
||||
'check',
|
||||
`${check.key}:${check.status}`,
|
||||
check.message
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: string[][]) {
|
||||
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvCell(value: string) {
|
||||
const normalized = value.replace(/\r?\n/g, ' ');
|
||||
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-toolbar">
|
||||
<el-form class="stats-filter" label-position="top">
|
||||
<el-form-item label="周期">
|
||||
<el-segmented :options="quickOptions" @update:model-value="applyQuickRange" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开始">
|
||||
<el-input v-model="filterDraft.from" placeholder="2026-06-01" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束">
|
||||
<el-input v-model="filterDraft.to" placeholder="2026-07-01" />
|
||||
</el-form-item>
|
||||
<el-form-item label="门店 ID">
|
||||
<el-input v-model="filterDraft.storeId" inputmode="numeric" />
|
||||
</el-form-item>
|
||||
<el-form-item label="保洁员">
|
||||
<el-select
|
||||
v-model="filterDraft.cleanerUserId"
|
||||
class="filter-select"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="保洁员"
|
||||
>
|
||||
<el-option
|
||||
v-for="cleaner in cleaners"
|
||||
:key="cleaner.id"
|
||||
:label="cleanerLabel(cleaner)"
|
||||
:value="cleaner.id"
|
||||
>
|
||||
<div class="option-row">
|
||||
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
||||
<span>{{ cleaner.maskedPhone || '未留手机' }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="emitFilter">查询</el-button>
|
||||
</el-form>
|
||||
<el-button :icon="Download" @click="exportStatistics">导出</el-button>
|
||||
<el-tooltip content="刷新统计">
|
||||
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="stats-body" v-loading="loading">
|
||||
<section class="metric-grid compact" aria-label="统计概览">
|
||||
<div class="metric">
|
||||
<span>任务总数</span>
|
||||
<strong>{{ statistics.summary.taskTotal }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>待验收</span>
|
||||
<strong>{{ statistics.summary.pendingReview }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>待结算</span>
|
||||
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>{{ '\u514d\u6e05\u6d01' }}</span>
|
||||
<strong>{{ statistics.summary.exempted }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>已发放</span>
|
||||
<strong>{{ money(statistics.summary.paidSettlementCents) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-block cleaner-performance-board">
|
||||
<header>
|
||||
<div>
|
||||
<h3>保洁员绩效排行</h3>
|
||||
<span>任务 / 完成率 / 驳回 / 结算</span>
|
||||
</div>
|
||||
<el-button :icon="Download" :disabled="cleanerPerformanceRows.length === 0" @click="exportCleanerPerformance">
|
||||
导出排行
|
||||
</el-button>
|
||||
</header>
|
||||
<div class="cleaner-performance-grid">
|
||||
<button
|
||||
v-for="row in cleanerPerformanceRows"
|
||||
:key="row.cleanerUserId"
|
||||
type="button"
|
||||
class="cleaner-performance-card"
|
||||
@click="filterByCleaner(row.cleanerUserId)"
|
||||
>
|
||||
<span>第 {{ row.rank }} 名</span>
|
||||
<strong>{{ row.cleanerName || compactText(row.cleanerUserId) }}</strong>
|
||||
<em>任务 {{ row.taskCount }} · 完成 {{ row.completedTaskCount }} · 驳回 {{ row.rejectedTaskCount }}</em>
|
||||
<small>完成率 {{ row.completionRate }}% · 待结算 {{ money(row.pendingSettlementCents) }} · 已结算 {{ money(row.settledRewardCents) }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<el-empty v-if="cleanerPerformanceRows.length === 0" description="暂无保洁员绩效数据" :image-size="72" />
|
||||
</section>
|
||||
|
||||
<div class="stats-grid">
|
||||
<section class="stat-block">
|
||||
<header>
|
||||
<h3>任务状态</h3>
|
||||
<span>{{ money(statistics.summary.rewardCents) }}</span>
|
||||
</header>
|
||||
<el-table :data="statistics.byStatus" size="small">
|
||||
<el-table-column prop="status" label="状态" />
|
||||
<el-table-column prop="total" label="数量" width="90" />
|
||||
<el-table-column prop="rewardCents" label="奖励" width="120">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="stat-block">
|
||||
<header>
|
||||
<h3>结算口径</h3>
|
||||
<span>待发 {{ money(statistics.summary.confirmedSettlementCents) }}</span>
|
||||
</header>
|
||||
<el-table :data="statistics.settlements" size="small">
|
||||
<el-table-column prop="status" label="状态" />
|
||||
<el-table-column prop="total" label="单数" width="90" />
|
||||
<el-table-column prop="rewardCents" label="金额" width="120">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="stat-block">
|
||||
<header>
|
||||
<h3>每日趋势</h3>
|
||||
<span>任务 / 待验 / 发放</span>
|
||||
</header>
|
||||
<div class="trend-list">
|
||||
<div v-for="row in statistics.trend" :key="row.date" class="trend-row">
|
||||
<div class="trend-date">{{ row.date }}</div>
|
||||
<div class="trend-track" aria-label="每日任务趋势">
|
||||
<span class="trend-bar" :style="{ width: trendWidth(row.taskTotal) }" />
|
||||
</div>
|
||||
<div class="trend-meta">
|
||||
<strong>{{ row.taskTotal }}</strong>
|
||||
<span>待验 {{ row.pendingReview }}</span>
|
||||
<span>完成 {{ row.completed }}</span>
|
||||
<span>驳回 {{ row.rejected }}</span>
|
||||
<span>{{ '\u514d\u6e05\u6d01' }} {{ row.exempted }}</span>
|
||||
<span>奖励 {{ money(row.rewardCents) }}</span>
|
||||
<span>发放 {{ money(row.paidSettlementCents) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="statistics.trend.length === 0" description="暂无趋势数据" :image-size="72" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-block">
|
||||
<header>
|
||||
<h3>门店明细</h3>
|
||||
<span>按待验收优先</span>
|
||||
</header>
|
||||
<el-table :data="statistics.byStore" size="small">
|
||||
<el-table-column prop="storeName" label="门店" min-width="150" />
|
||||
<el-table-column prop="taskTotal" label="任务" width="90" />
|
||||
<el-table-column prop="pendingReview" label="待验" width="90" />
|
||||
<el-table-column prop="completed" label="完成" width="90" />
|
||||
<el-table-column prop="rejected" label="驳回" width="90" />
|
||||
<el-table-column prop="exempted" :label="'\u514d\u6e05\u6d01'" width="90" />
|
||||
<el-table-column prop="rewardCents" label="奖励" width="120">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="stat-block">
|
||||
<header>
|
||||
<h3>成员分账</h3>
|
||||
<span>按待结算金额排序</span>
|
||||
</header>
|
||||
<el-table :data="statistics.members" size="small">
|
||||
<el-table-column prop="cleanerName" label="保洁员" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.cleanerName || compactText(row.cleanerUserId) }}</strong>
|
||||
<span>ID {{ row.cleanerUserId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="taskCount" label="任务" width="90" />
|
||||
<el-table-column prop="completedTaskCount" label="完成" width="90" />
|
||||
<el-table-column prop="rejectedTaskCount" label="驳回" width="90" />
|
||||
<el-table-column prop="pendingSettlementCents" label="待结算" width="130">
|
||||
<template #default="{ row }">{{ money(row.pendingSettlementCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settledRewardCents" label="已结算" width="130">
|
||||
<template #default="{ row }">{{ money(row.settledRewardCents) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { Download, RefreshCw, Search } from '@lucide/vue';
|
||||
import { compactText, money } from '../format';
|
||||
import type { CleaningStatistics, ManagedUser } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean;
|
||||
statistics: CleaningStatistics;
|
||||
filters: { from: string; to: string; storeId: string; cleanerUserId: string };
|
||||
cleaners: ManagedUser[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
filter: [{ from: string; to: string; storeId: string; cleanerUserId: string }];
|
||||
}>();
|
||||
|
||||
const filterDraft = reactive({ ...props.filters });
|
||||
const quickOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '近7天', value: '7d' },
|
||||
{ label: '本月', value: 'month' }
|
||||
];
|
||||
const maxTrendTotal = computed(() => Math.max(1, ...props.statistics.trend.map((row) => row.taskTotal)));
|
||||
const cleanerPerformanceRows = computed(() => props.statistics.members
|
||||
.map((row) => ({
|
||||
...row,
|
||||
completionRate: row.taskCount ? Math.round((row.completedTaskCount / row.taskCount) * 100) : 0,
|
||||
settlementTotalCents: row.pendingSettlementCents + row.settledRewardCents
|
||||
}))
|
||||
.sort((a, b) => (
|
||||
b.taskCount - a.taskCount
|
||||
|| b.completedTaskCount - a.completedTaskCount
|
||||
|| a.rejectedTaskCount - b.rejectedTaskCount
|
||||
|| b.settlementTotalCents - a.settlementTotalCents
|
||||
))
|
||||
.slice(0, 8)
|
||||
.map((row, index) => ({ ...row, rank: index + 1 })));
|
||||
|
||||
watch(
|
||||
() => props.filters,
|
||||
(value) => Object.assign(filterDraft, value),
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
function emitFilter() {
|
||||
emit('filter', {
|
||||
from: filterDraft.from.trim(),
|
||||
to: filterDraft.to.trim(),
|
||||
storeId: filterDraft.storeId.trim(),
|
||||
cleanerUserId: filterDraft.cleanerUserId.trim()
|
||||
});
|
||||
}
|
||||
|
||||
function cleanerLabel(cleaner: ManagedUser) {
|
||||
return `${cleaner.nickname || compactText(cleaner.id)} / ${cleaner.maskedPhone || '未留手机'}`;
|
||||
}
|
||||
|
||||
function applyQuickRange(value: string | number | boolean) {
|
||||
const now = new Date();
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
let start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
if (value === '7d') {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6);
|
||||
}
|
||||
if (value === 'month') {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
filterDraft.from = formatDate(start);
|
||||
filterDraft.to = formatDate(end);
|
||||
emitFilter();
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function trendWidth(total: number) {
|
||||
return `${Math.max(6, Math.round((total / maxTrendTotal.value) * 100))}%`;
|
||||
}
|
||||
|
||||
function filterByCleaner(cleanerUserId: string) {
|
||||
filterDraft.cleanerUserId = cleanerUserId;
|
||||
emitFilter();
|
||||
}
|
||||
|
||||
function exportCleanerPerformance() {
|
||||
downloadCsv(`cleaning-cleaner-performance-${Date.now()}.csv`, [
|
||||
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算', '已结算'],
|
||||
...cleanerPerformanceRows.value.map((row) => [
|
||||
String(row.rank),
|
||||
row.cleanerName || '',
|
||||
row.cleanerUserId,
|
||||
String(row.taskCount),
|
||||
String(row.completedTaskCount),
|
||||
String(row.rejectedTaskCount),
|
||||
`${row.completionRate}%`,
|
||||
money(row.pendingSettlementCents),
|
||||
money(row.settledRewardCents)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function exportStatistics() {
|
||||
downloadCsv(`cleaning-statistics-${Date.now()}.csv`, [
|
||||
['section', 'key', 'value', 'extra1', 'extra2', 'extra3', 'extra4'],
|
||||
['filter', 'from', filterDraft.from || '\u672a\u8bbe\u7f6e', '', '', '', ''],
|
||||
['filter', 'to', filterDraft.to || '\u672a\u8bbe\u7f6e', '', '', '', ''],
|
||||
['filter', 'storeId', filterDraft.storeId || '\u5168\u90e8\u95e8\u5e97', '', '', '', ''],
|
||||
['filter', 'cleanerUserId', filterDraft.cleanerUserId || '\u5168\u90e8\u4fdd\u6d01\u5458', '', '', '', ''],
|
||||
['summary', '\u4efb\u52a1\u603b\u6570', String(props.statistics.summary.taskTotal), '', '', '', ''],
|
||||
['summary', '\u5f85\u9a8c\u6536', String(props.statistics.summary.pendingReview), '', '', '', ''],
|
||||
['summary', '\u8fdb\u884c\u4e2d', String(props.statistics.summary.active), '', '', '', ''],
|
||||
['summary', '\u9a73\u56de', String(props.statistics.summary.rejected), '', '', '', ''],
|
||||
['summary', '\u514d\u6e05\u6d01', String(props.statistics.summary.exempted), '', '', '', ''],
|
||||
['summary', '\u5df2\u5b8c\u6210', String(props.statistics.summary.completed), '', '', '', ''],
|
||||
['summary', '\u5956\u52b1\u5408\u8ba1', money(props.statistics.summary.rewardCents), '', '', '', ''],
|
||||
['summary', '\u5f85\u7ed3\u7b97', money(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
|
||||
['summary', '\u5f85\u53d1\u653e', money(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
|
||||
['summary', '\u5df2\u53d1\u653e', money(props.statistics.summary.paidSettlementCents), '', '', '', ''],
|
||||
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1', '', '', ''],
|
||||
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), money(row.rewardCents), '', '', '']),
|
||||
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d', '', '', ''],
|
||||
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), money(row.rewardCents), '', '', '']),
|
||||
['\u6bcf\u65e5\u8d8b\u52bf', '\u65e5\u671f', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
|
||||
...props.statistics.trend.map((row) => [
|
||||
'\u6bcf\u65e5\u8d8b\u52bf',
|
||||
row.date,
|
||||
String(row.taskTotal),
|
||||
String(row.pendingReview),
|
||||
String(row.completed),
|
||||
String(row.rejected),
|
||||
String(row.exempted)
|
||||
]),
|
||||
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e', '', '', '', ''],
|
||||
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, money(row.paidSettlementCents), '', '', '', '']),
|
||||
['\u95e8\u5e97\u660e\u7ec6', '\u95e8\u5e97', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
|
||||
...props.statistics.byStore.map((row) => [
|
||||
'\u95e8\u5e97\u660e\u7ec6',
|
||||
`${row.storeName || ''}(${row.storeId})`,
|
||||
String(row.taskTotal),
|
||||
String(row.pendingReview),
|
||||
String(row.completed),
|
||||
String(row.rejected),
|
||||
String(row.exempted)
|
||||
]),
|
||||
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1', '', '', '', ''],
|
||||
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, money(row.rewardCents), '', '', '', '']),
|
||||
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97', '\u5df2\u7ed3\u7b97'],
|
||||
...props.statistics.members.map((row) => [
|
||||
'\u6210\u5458\u5206\u8d26',
|
||||
`${row.cleanerName || ''}(${row.cleanerUserId})`,
|
||||
String(row.taskCount),
|
||||
String(row.completedTaskCount),
|
||||
String(row.rejectedTaskCount),
|
||||
money(row.pendingSettlementCents),
|
||||
money(row.settledRewardCents)
|
||||
]),
|
||||
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1'],
|
||||
...cleanerPerformanceRows.value.map((row) => [
|
||||
'\u7ee9\u6548\u6392\u884c',
|
||||
String(row.rank),
|
||||
`${row.cleanerName || ''}(${row.cleanerUserId})`,
|
||||
String(row.taskCount),
|
||||
`${row.completionRate}%`,
|
||||
String(row.rejectedTaskCount),
|
||||
money(row.settlementTotalCents)
|
||||
])
|
||||
]);
|
||||
}
|
||||
function downloadCsv(filename: string, rows: string[][]) {
|
||||
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvCell(value: string) {
|
||||
return `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,837 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-actions">
|
||||
<el-segmented
|
||||
:model-value="status"
|
||||
:options="statusOptions"
|
||||
@update:model-value="$emit('status-change', $event as TaskStatus | '')"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filterDraft.storeId"
|
||||
class="narrow-input"
|
||||
placeholder="门店 ID"
|
||||
clearable
|
||||
@keyup.enter="submitFilters"
|
||||
@clear="submitFilters"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterDraft.cleanerUserId"
|
||||
class="filter-select"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="保洁员"
|
||||
@change="submitFilters"
|
||||
@clear="submitFilters"
|
||||
>
|
||||
<el-option
|
||||
v-for="cleaner in cleaners"
|
||||
:key="cleaner.id"
|
||||
:label="cleanerLabel(cleaner)"
|
||||
:value="cleaner.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button :icon="Search" @click="submitFilters">筛选</el-button>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="Download" :disabled="items.length === 0" @click="exportTasks">
|
||||
导出
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
:icon="BadgeCheck"
|
||||
:disabled="selectedSubmittedCount === 0"
|
||||
@click="submitBatchComplete"
|
||||
>
|
||||
批量验收
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Undo2"
|
||||
:disabled="selectedSubmittedCount === 0"
|
||||
@click="openBatchReject"
|
||||
>
|
||||
批量驳回
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
:icon="Ban"
|
||||
:disabled="selectedExemptableCount === 0"
|
||||
@click="submitBatchExempt"
|
||||
>
|
||||
{{ '\u6279\u91cf\u514d\u6e05\u6d01' }}
|
||||
</el-button>
|
||||
<el-popover trigger="click" width="280">
|
||||
<template #reference>
|
||||
<el-button :icon="RotateCcw">回收</el-button>
|
||||
</template>
|
||||
<el-form label-position="top" class="compact-form">
|
||||
<el-form-item label="分钟">
|
||||
<el-input-number v-model="reclaimForm.olderThanMinutes" :min="5" :max="1440" />
|
||||
</el-form-item>
|
||||
<el-form-item label="数量">
|
||||
<el-input-number v-model="reclaimForm.limit" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :icon="Check" @click="$emit('reclaim', reclaimForm)">
|
||||
执行
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-popover>
|
||||
<el-tooltip content="刷新任务">
|
||||
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
class="data-table"
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="44" :selectable="canSelectTask" />
|
||||
<el-table-column prop="taskNo" label="任务" min-width="180" fixed>
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.taskNo }}</strong>
|
||||
<span>{{ row.storeName }} · {{ row.roomName || row.roomNo }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="taskTag(row.status)" effect="light">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cleanerUserId" label="保洁员" width="110">
|
||||
<template #default="{ row }">{{ compactText(row.cleanerUserId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="memberCount" label="成员" width="90" />
|
||||
<el-table-column prop="rewardCents" label="奖励" width="110">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submittedAt" label="提交" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.submittedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="照片" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-badge :value="row.photoUrls?.length || 0" :hidden="!row.photoUrls?.length">
|
||||
<Image :size="18" />
|
||||
</el-badge>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="340" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
|
||||
<el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button>
|
||||
<el-button size="small" :icon="Users" @click="openMembers(row)">成员</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
:icon="BadgeCheck"
|
||||
:disabled="row.status !== 'SUBMITTED'"
|
||||
@click="$emit('complete', { taskId: row.id, note: '后台验收通过' })"
|
||||
>
|
||||
验收
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
:icon="Undo2"
|
||||
:disabled="row.status !== 'SUBMITTED'"
|
||||
@click="openReject(row)"
|
||||
>
|
||||
驳回
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="warning"
|
||||
:icon="Ban"
|
||||
:disabled="!canExempt(row.status)"
|
||||
@click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })"
|
||||
>
|
||||
{{ '\u514d\u6e05\u6d01' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
layout="prev, pager, next, total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="$emit('page-change', $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="assignDialog.open" title="指派保洁员" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="保洁员">
|
||||
<el-select
|
||||
v-model="assignDialog.cleanerUserId"
|
||||
class="cleaner-select"
|
||||
filterable
|
||||
placeholder="选择保洁员"
|
||||
:disabled="!cleaners.length"
|
||||
>
|
||||
<el-option
|
||||
v-for="cleaner in cleaners"
|
||||
:key="cleaner.id"
|
||||
:label="cleanerLabel(cleaner)"
|
||||
:value="cleaner.id"
|
||||
>
|
||||
<div class="option-row">
|
||||
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
||||
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
|
||||
</div>
|
||||
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
|
||||
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
|
||||
</el-tag>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="assignDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitAssign">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="rejectDialog.open" title="驳回任务" width="420px">
|
||||
<el-input v-model="rejectDialog.reason" type="textarea" :rows="4" maxlength="512" show-word-limit />
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialog.open = false">取消</el-button>
|
||||
<el-button type="danger" @click="submitReject">驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="batchRejectDialog.open" title="批量驳回待验收任务" width="460px">
|
||||
<div class="dialog-stack">
|
||||
<el-alert
|
||||
:title="`将驳回 ${selectedSubmittedCount} 个已选择的待验收任务`"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-input
|
||||
v-model="batchRejectDialog.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="512"
|
||||
show-word-limit
|
||||
placeholder="请输入统一驳回原因"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="batchRejectDialog.open = false">取消</el-button>
|
||||
<el-button type="danger" @click="submitBatchReject">批量驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="memberDialog.open" title="协作者管理" width="640px">
|
||||
<div class="dialog-stack">
|
||||
<div class="detail-header">
|
||||
<strong>{{ memberDialog.taskNo }}</strong>
|
||||
<span>任务奖励 {{ money(memberDialog.taskRewardCents) }}</span>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="Download" :disabled="!memberDialog.members.length" @click="exportTaskMembers">
|
||||
导出成员
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="memberDialog.members" size="small" v-loading="memberDialog.loading">
|
||||
<el-table-column prop="nickname" label="成员" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
|
||||
<span>ID {{ row.userId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="memberRole" label="角色" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
|
||||
{{ row.memberRole }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rewardCents" label="分账" width="110">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settledAt" label="结算" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
:icon="UserMinus"
|
||||
:disabled="row.memberRole === 'LEAD' || !!row.settledAt"
|
||||
@click="removeMember(row.userId)"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-form label-position="top" class="member-form">
|
||||
<el-form-item label="协作者">
|
||||
<el-select
|
||||
v-model="memberDialog.cleanerUserId"
|
||||
class="cleaner-select"
|
||||
filterable
|
||||
placeholder="选择协作者"
|
||||
:disabled="!cleaners.length"
|
||||
>
|
||||
<el-option
|
||||
v-for="cleaner in cleaners"
|
||||
:key="cleaner.id"
|
||||
:label="cleanerLabel(cleaner)"
|
||||
:value="cleaner.id"
|
||||
>
|
||||
<div class="option-row">
|
||||
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
||||
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
|
||||
</div>
|
||||
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
|
||||
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
|
||||
</el-tag>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分账金额">
|
||||
<el-input-number v-model="memberDialog.rewardCents" :min="0" :max="1000000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="memberDialog.note" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :icon="UserPlus" @click="submitMember">添加/更新</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="memberDialog.open = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="760px">
|
||||
<div v-if="detailDialog.task" class="dialog-stack">
|
||||
<div class="detail-grid">
|
||||
<span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span>
|
||||
<span>状态<strong>{{ detailDialog.task.status }}</strong></span>
|
||||
<span>奖励<strong>{{ money(detailDialog.task.rewardCents) }}</strong></span>
|
||||
<span>保洁员<strong>{{ compactText(detailDialog.task.cleanerUserId) }}</strong></span>
|
||||
</div>
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item label="门店房间">
|
||||
{{ detailDialog.task.storeName }} · {{ detailDialog.task.roomName || detailDialog.task.roomNo }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单">
|
||||
{{ compactText(detailDialog.task.orderNo) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="要求" :span="2">
|
||||
{{ compactText(detailDialog.task.requirement) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="驳回原因" :span="2">
|
||||
{{ compactText(detailDialog.task.rejectReason) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="timeline-grid">
|
||||
<span>领取<strong>{{ shortDate(detailDialog.task.claimedAt) }}</strong></span>
|
||||
<span>开始<strong>{{ shortDate(detailDialog.task.startedAt) }}</strong></span>
|
||||
<span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span>
|
||||
<span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span>
|
||||
</div>
|
||||
<section class="stat-block">
|
||||
<header>
|
||||
<h3>操作流水</h3>
|
||||
<span>最近 {{ detailDialog.events.length }} 条</span>
|
||||
</header>
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="Download" :disabled="!detailDialog.events.length" @click="exportTaskEvents">
|
||||
导出流水
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="detailDialog.events" size="small" v-loading="detailDialog.loadingEvents">
|
||||
<el-table-column prop="action" label="动作" width="130" />
|
||||
<el-table-column label="状态" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ compactText(row.fromStatus) }} → {{ row.toStatus }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorId" label="操作人" width="100">
|
||||
<template #default="{ row }">{{ compactText(row.actorId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" label="备注" min-width="160">
|
||||
<template #default="{ row }">{{ compactText(row.note) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="时间" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<section class="stat-block">
|
||||
<header>
|
||||
<h3>协作分账</h3>
|
||||
<span>{{ detailDialog.members.length }} 人</span>
|
||||
</header>
|
||||
<el-table :data="detailDialog.members" size="small" v-loading="detailDialog.loadingMembers">
|
||||
<el-table-column prop="nickname" label="成员" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
|
||||
<span>ID {{ row.userId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="memberRole" label="角色" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
|
||||
{{ row.memberRole }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rewardCents" label="分账" width="110">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settledAt" label="结算" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<div class="photo-grid" v-if="detailDialog.task.photoUrls.length">
|
||||
<el-image
|
||||
v-for="url in detailDialog.task.photoUrls"
|
||||
:key="url"
|
||||
:src="url"
|
||||
fit="cover"
|
||||
:preview-src-list="detailDialog.task.photoUrls"
|
||||
preview-teleported
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-else description="暂无保洁照片" :image-size="80" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :icon="Download" :disabled="!detailDialog.task" @click="exportTaskDetail">
|
||||
导出详情
|
||||
</el-button>
|
||||
<el-button @click="detailDialog.open = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import {
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Check,
|
||||
Download,
|
||||
Image,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Undo2,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users
|
||||
} from '@lucide/vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
addCleaningTaskMember,
|
||||
listCleaningTaskEvents,
|
||||
listCleaningTaskMembers,
|
||||
removeCleaningTaskMember
|
||||
} from '../api';
|
||||
import { compactText, money, shortDate } from '../format';
|
||||
import type {
|
||||
CleaningTask,
|
||||
CleaningTaskEvent,
|
||||
CleaningTaskMember,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
TaskStatus
|
||||
} from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
session: { token: string };
|
||||
loading: boolean;
|
||||
items: PageResult<CleaningTask>['items'];
|
||||
cleaners: ManagedUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status: TaskStatus | '';
|
||||
filters: { storeId: string; cleanerUserId: string };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
'status-change': [TaskStatus | ''];
|
||||
filter: [{ storeId: string; cleanerUserId: string }];
|
||||
'page-change': [number];
|
||||
assign: [{ taskId: string; cleanerUserId: string; note?: string }];
|
||||
complete: [{ taskId: string; note?: string }];
|
||||
'complete-many': [{ taskIds: string[]; note?: string }];
|
||||
reject: [{ taskId: string; reason: string }];
|
||||
'reject-many': [{ taskIds: string[]; reason: string }];
|
||||
exempt: [{ taskId: string; note?: string }];
|
||||
'exempt-many': [{ taskIds: string[]; note?: string }];
|
||||
reclaim: [{ olderThanMinutes: number; limit: number }];
|
||||
}>();
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待抢', value: 'WAITING' },
|
||||
{ label: '已抢', value: 'CLAIMED' },
|
||||
{ label: '进行', value: 'STARTED' },
|
||||
{ label: '待验', value: 'SUBMITTED' },
|
||||
{ label: '完成', value: 'COMPLETED' },
|
||||
{ label: '\u9a73\u56de', value: 'REJECTED' },
|
||||
{ label: '\u514d\u6e05\u6d01', value: 'EXEMPT' }
|
||||
];
|
||||
const filterDraft = reactive({ storeId: props.filters.storeId, cleanerUserId: props.filters.cleanerUserId });
|
||||
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
|
||||
const selectedTasks = ref<CleaningTask[]>([]);
|
||||
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
|
||||
const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
|
||||
const batchRejectDialog = reactive({ open: false, reason: '' });
|
||||
const detailDialog = reactive<{
|
||||
open: boolean;
|
||||
loadingEvents: boolean;
|
||||
loadingMembers: boolean;
|
||||
task: CleaningTask | null;
|
||||
events: CleaningTaskEvent[];
|
||||
members: CleaningTaskMember[];
|
||||
}>({
|
||||
open: false,
|
||||
loadingEvents: false,
|
||||
loadingMembers: false,
|
||||
task: null,
|
||||
events: [],
|
||||
members: []
|
||||
});
|
||||
const memberDialog = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
taskId: '',
|
||||
taskNo: '',
|
||||
taskRewardCents: 0,
|
||||
cleanerUserId: '',
|
||||
rewardCents: 0,
|
||||
note: '',
|
||||
members: [] as CleaningTaskMember[]
|
||||
});
|
||||
const selectedSubmittedTasks = computed(() => selectedTasks.value
|
||||
.filter((task) => task.status === 'SUBMITTED'));
|
||||
const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length);
|
||||
const selectedExemptableTasks = computed(() => selectedTasks.value
|
||||
.filter((task) => canExempt(task.status)));
|
||||
const selectedExemptableCount = computed(() => selectedExemptableTasks.value.length);
|
||||
|
||||
watch(
|
||||
() => props.filters,
|
||||
(value) => Object.assign(filterDraft, value),
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
function taskTag(status: TaskStatus) {
|
||||
if (status === 'SUBMITTED') return 'warning';
|
||||
if (status === 'COMPLETED' || status === 'SETTLED') return 'success';
|
||||
if (status === 'REJECTED') return 'danger';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function canSelectTask(row: CleaningTask) {
|
||||
return row.status === 'SUBMITTED' || canExempt(row.status);
|
||||
}
|
||||
|
||||
function canExempt(status: TaskStatus) {
|
||||
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(status);
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: CleaningTask[]) {
|
||||
selectedTasks.value = rows;
|
||||
}
|
||||
|
||||
function cleanerLabel(cleaner: ManagedUser) {
|
||||
const name = cleaner.nickname || `ID ${cleaner.id}`;
|
||||
const phone = cleaner.maskedPhone ? ` · ${cleaner.maskedPhone}` : '';
|
||||
const stores = cleaner.storeIds.length ? ` · 门店 ${cleaner.storeIds.join('/')}` : '';
|
||||
return `${name}${phone}${stores}`;
|
||||
}
|
||||
|
||||
function submitFilters() {
|
||||
emit('filter', {
|
||||
storeId: filterDraft.storeId.trim(),
|
||||
cleanerUserId: filterDraft.cleanerUserId
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(row: CleaningTask) {
|
||||
detailDialog.open = true;
|
||||
detailDialog.task = row;
|
||||
detailDialog.events = [];
|
||||
detailDialog.members = [];
|
||||
detailDialog.loadingEvents = true;
|
||||
detailDialog.loadingMembers = true;
|
||||
try {
|
||||
const [events, members] = await Promise.all([
|
||||
listCleaningTaskEvents(props.session, row.id),
|
||||
listCleaningTaskMembers(props.session, row.id)
|
||||
]);
|
||||
detailDialog.events = events;
|
||||
detailDialog.members = members;
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败');
|
||||
} finally {
|
||||
detailDialog.loadingEvents = false;
|
||||
detailDialog.loadingMembers = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAssign(row: CleaningTask) {
|
||||
assignDialog.open = true;
|
||||
assignDialog.taskId = row.id;
|
||||
assignDialog.cleanerUserId = row.cleanerUserId || '';
|
||||
assignDialog.note = '';
|
||||
}
|
||||
|
||||
function submitAssign() {
|
||||
if (!assignDialog.cleanerUserId) {
|
||||
ElMessage.warning('请选择保洁员');
|
||||
return;
|
||||
}
|
||||
emit('assign', {
|
||||
taskId: assignDialog.taskId,
|
||||
cleanerUserId: assignDialog.cleanerUserId,
|
||||
note: assignDialog.note
|
||||
});
|
||||
assignDialog.open = false;
|
||||
}
|
||||
|
||||
function openReject(row: CleaningTask) {
|
||||
rejectDialog.open = true;
|
||||
rejectDialog.taskId = row.id;
|
||||
rejectDialog.reason = row.rejectReason || '';
|
||||
}
|
||||
|
||||
function submitReject() {
|
||||
emit('reject', { taskId: rejectDialog.taskId, reason: rejectDialog.reason });
|
||||
rejectDialog.open = false;
|
||||
}
|
||||
|
||||
function submitBatchComplete() {
|
||||
const taskIds = selectedSubmittedTasks.value.map((task) => task.id);
|
||||
if (!taskIds.length) {
|
||||
ElMessage.warning('请选择待验收任务');
|
||||
return;
|
||||
}
|
||||
emit('complete-many', { taskIds, note: '后台批量验收通过' });
|
||||
}
|
||||
|
||||
function openBatchReject() {
|
||||
if (!selectedSubmittedCount.value) {
|
||||
ElMessage.warning('请选择待验收任务');
|
||||
return;
|
||||
}
|
||||
batchRejectDialog.open = true;
|
||||
batchRejectDialog.reason = '';
|
||||
}
|
||||
|
||||
function submitBatchReject() {
|
||||
const reason = batchRejectDialog.reason.trim();
|
||||
if (!reason) {
|
||||
ElMessage.warning('请输入驳回原因');
|
||||
return;
|
||||
}
|
||||
const taskIds = selectedSubmittedTasks.value.map((task) => task.id);
|
||||
emit('reject-many', { taskIds, reason });
|
||||
batchRejectDialog.open = false;
|
||||
}
|
||||
|
||||
function submitBatchExempt() {
|
||||
const taskIds = selectedExemptableTasks.value.map((task) => task.id);
|
||||
if (!taskIds.length) {
|
||||
ElMessage.warning('\u8bf7\u9009\u62e9\u53ef\u514d\u6e05\u6d01\u4efb\u52a1');
|
||||
return;
|
||||
}
|
||||
emit('exempt-many', {
|
||||
taskIds,
|
||||
note: '\u540e\u53f0\u6279\u91cf\u6807\u8bb0\u514d\u6e05\u6d01'
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
memberDialog.loading = true;
|
||||
try {
|
||||
memberDialog.members = await listCleaningTaskMembers(props.session, memberDialog.taskId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '成员加载失败');
|
||||
} finally {
|
||||
memberDialog.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openMembers(row: CleaningTask) {
|
||||
memberDialog.open = true;
|
||||
memberDialog.taskId = row.id;
|
||||
memberDialog.taskNo = row.taskNo;
|
||||
memberDialog.taskRewardCents = row.rewardCents;
|
||||
memberDialog.cleanerUserId = '';
|
||||
memberDialog.rewardCents = 0;
|
||||
memberDialog.note = '';
|
||||
await loadMembers();
|
||||
}
|
||||
|
||||
async function submitMember() {
|
||||
if (!memberDialog.cleanerUserId) {
|
||||
ElMessage.warning('请选择协作者');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
memberDialog.members = await addCleaningTaskMember(props.session, memberDialog.taskId, {
|
||||
cleanerUserId: memberDialog.cleanerUserId,
|
||||
rewardCents: memberDialog.rewardCents,
|
||||
note: memberDialog.note
|
||||
});
|
||||
memberDialog.cleanerUserId = '';
|
||||
memberDialog.rewardCents = 0;
|
||||
memberDialog.note = '';
|
||||
ElMessage.success('协作者已更新');
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '协作者更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(cleanerUserId: string) {
|
||||
try {
|
||||
memberDialog.members = await removeCleaningTaskMember(
|
||||
props.session,
|
||||
memberDialog.taskId,
|
||||
cleanerUserId,
|
||||
'后台移除协作者'
|
||||
);
|
||||
ElMessage.success('协作者已移除');
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '协作者移除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function exportTasks() {
|
||||
downloadCsv(`cleaning-tasks-${Date.now()}.csv`, [
|
||||
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额', '照片数', '提交时间', '完成时间', '驳回原因'],
|
||||
...props.items.map((item) => [
|
||||
item.taskNo,
|
||||
item.status,
|
||||
item.storeName,
|
||||
item.roomName || item.roomNo,
|
||||
item.orderNo || '',
|
||||
item.cleanerUserId || '',
|
||||
String(item.memberCount || 0),
|
||||
money(item.rewardCents),
|
||||
String(item.photoUrls?.length || 0),
|
||||
shortDate(item.submittedAt),
|
||||
shortDate(item.completedAt),
|
||||
item.rejectReason || ''
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function exportTaskDetail() {
|
||||
const task = detailDialog.task;
|
||||
if (!task) return;
|
||||
downloadCsv(`cleaning-task-detail-${task.taskNo}-${Date.now()}.csv`, [
|
||||
['类别', '字段', '值'],
|
||||
['task', '任务号', task.taskNo],
|
||||
['task', '状态', task.status],
|
||||
['task', '门店', task.storeName],
|
||||
['task', '房间', task.roomName || task.roomNo],
|
||||
['task', '订单号', task.orderNo || ''],
|
||||
['task', '保洁员ID', task.cleanerUserId || ''],
|
||||
['task', '奖励金额', money(task.rewardCents)],
|
||||
['task', '照片数', String(task.photoUrls?.length || 0)],
|
||||
['task', '领取时间', shortDate(task.claimedAt)],
|
||||
['task', '开始时间', shortDate(task.startedAt)],
|
||||
['task', '提交时间', shortDate(task.submittedAt)],
|
||||
['task', '完成时间', shortDate(task.completedAt)],
|
||||
['task', '保洁要求', task.requirement || ''],
|
||||
['task', '驳回原因', task.rejectReason || ''],
|
||||
...detailDialog.members.map((member) => [
|
||||
'member',
|
||||
`${member.memberRole}:${member.userId}`,
|
||||
`${member.nickname || ''} ${money(member.rewardCents)} ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
|
||||
]),
|
||||
...detailDialog.events.map((event) => [
|
||||
'event',
|
||||
`${event.action}:${shortDate(event.createdAt)}`,
|
||||
`${compactText(event.fromStatus)} -> ${event.toStatus} ${compactText(event.actorId)} ${compactText(event.note)}`
|
||||
]),
|
||||
...task.photoUrls.map((url, index) => [
|
||||
'photo',
|
||||
`photo_${index + 1}`,
|
||||
url
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function exportTaskEvents() {
|
||||
const task = detailDialog.task;
|
||||
if (!task) return;
|
||||
downloadCsv(`cleaning-task-events-${task.taskNo}-${Date.now()}.csv`, [
|
||||
['任务号', '动作', '原状态', '新状态', '操作人', 'TraceId', '备注', '时间'],
|
||||
...detailDialog.events.map((event) => [
|
||||
task.taskNo,
|
||||
event.action,
|
||||
event.fromStatus || '',
|
||||
event.toStatus,
|
||||
event.actorId,
|
||||
event.traceId,
|
||||
event.note,
|
||||
shortDate(event.createdAt)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function exportTaskMembers() {
|
||||
downloadCsv(`cleaning-task-members-${memberDialog.taskNo}-${Date.now()}.csv`, [
|
||||
['任务号', '成员ID', '昵称', '角色', '分账金额', '结算时间'],
|
||||
...memberDialog.members.map((member) => [
|
||||
memberDialog.taskNo,
|
||||
member.userId,
|
||||
member.nickname || '',
|
||||
member.memberRole,
|
||||
money(member.rewardCents),
|
||||
shortDate(member.settledAt)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: string[][]) {
|
||||
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvCell(value: string) {
|
||||
const normalized = value.replace(/\r?\n/g, ' ');
|
||||
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
export function money(cents: number) {
|
||||
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function shortDate(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function compactText(value?: string | null) {
|
||||
return value && value.trim().length > 0 ? value : '-';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles.css';
|
||||
import App from './App.vue';
|
||||
|
||||
createApp(App).use(ElementPlus).mount('#app');
|
||||
@@ -0,0 +1,995 @@
|
||||
:root {
|
||||
color: #18212f;
|
||||
background: #eef2f6;
|
||||
font-family:
|
||||
Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 247, 250, 0.96), rgba(231, 237, 243, 0.96)),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cg fill='none' stroke='%23d5dde7' stroke-width='1'%3E%3Cpath d='M0 40h120M0 80h120M40 0v120M80 0v120'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #152033;
|
||||
color: #f8fafc;
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 8px;
|
||||
background: #e8f1ff;
|
||||
color: #152033;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand h1,
|
||||
.brand p,
|
||||
.workspace h2,
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin-top: 3px;
|
||||
color: #aab7c8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #cbd6e5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #e8f1ff;
|
||||
color: #162033;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-item:disabled {
|
||||
color: #6f7d90;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #4d6b92;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.workspace h2 {
|
||||
margin-top: 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 360px) 40px;
|
||||
gap: 8px;
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 20px 0 14px;
|
||||
}
|
||||
|
||||
.metric-grid.compact {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 82px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: #60708a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.action-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.action-tile {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 10px 12px;
|
||||
color: #1f3656;
|
||||
text-align: left;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-tile:hover {
|
||||
border-color: #8cb5ec;
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
.action-tile span {
|
||||
overflow: hidden;
|
||||
color: #536781;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-tile strong {
|
||||
color: #18212f;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.alert-line {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.work-tabs {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid #e6ebf2;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.toolbar-actions,
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.narrow-input {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.profile-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cleaner-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: inline-grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.option-row strong,
|
||||
.option-row span {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option-row span {
|
||||
color: #69788c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stack strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stack span {
|
||||
color: #69788c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid #e6ebf2;
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preflight-summary {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.preflight-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preflight-meta span {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
overflow: hidden;
|
||||
color: #334155;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: #f5f8fb;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preflight-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transfer-preflight-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dialog-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.detail-header span {
|
||||
color: #60708a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.member-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 140px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.member-form .el-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-grid span,
|
||||
.timeline-grid span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.timeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.photo-grid .el-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.trend-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.trend-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(80px, 1fr) minmax(360px, 2.4fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.trend-date {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.trend-track {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
background: #e9eff5;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #2563eb;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.trend-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-meta strong {
|
||||
color: #101828;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-grid strong,
|
||||
.timeline-grid strong {
|
||||
overflow: hidden;
|
||||
color: #18212f;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 210px) repeat(3, minmax(130px, 180px)) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.stats-filter .el-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.handoff-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.handoff-summary span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
color: #52657f;
|
||||
font-size: 13px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.handoff-summary strong {
|
||||
color: #18212f;
|
||||
}
|
||||
|
||||
.handoff-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.handoff-session {
|
||||
padding: 12px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.handoff-session .el-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.handoff-session .el-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.handoff-category-progress {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.handoff-category-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.handoff-category-title,
|
||||
.handoff-category-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.handoff-category-title span,
|
||||
.handoff-category-meta {
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.handoff-stage-board {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d6e4dc;
|
||||
border-radius: 8px;
|
||||
background: #f8fcfa;
|
||||
}
|
||||
|
||||
.handoff-stage-board header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.handoff-stage-board h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.handoff-stage-board header span {
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.handoff-stage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.handoff-stage-card {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #d6e4dc;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.handoff-stage-card.blocked {
|
||||
border-color: #f0c7c7;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.handoff-stage-card.ready {
|
||||
border-color: #b8dec4;
|
||||
background: #f3fbf5;
|
||||
}
|
||||
|
||||
.handoff-stage-card span,
|
||||
.handoff-stage-card em,
|
||||
.handoff-stage-card small {
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.handoff-stage-card strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.handoff-owner-board {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d8e2ef;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.handoff-owner-board header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.handoff-owner-board h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.handoff-owner-board header span {
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.handoff-owner-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.handoff-owner-card {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #d8e2ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.handoff-owner-card.active {
|
||||
border-color: #2f6fed;
|
||||
background: #f2f6ff;
|
||||
}
|
||||
|
||||
.handoff-owner-card span {
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.handoff-verdict {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #f0c7c7;
|
||||
border-radius: 8px;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.handoff-verdict.ready {
|
||||
border-color: #b8dec4;
|
||||
background: #f3fbf5;
|
||||
}
|
||||
|
||||
.handoff-verdict h3,
|
||||
.handoff-verdict p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.handoff-verdict h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.handoff-verdict p {
|
||||
margin-top: 4px;
|
||||
color: #60708a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.handoff-status-select {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.handoff-search {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.handoff-bulk-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.handoff-bulk-form .el-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.handoff-blockers {
|
||||
border: 1px solid #f0c7c7;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.handoff-blockers header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f0c7c7;
|
||||
}
|
||||
|
||||
.handoff-blockers h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.handoff-blockers header span,
|
||||
.handoff-blockers li span {
|
||||
color: #7f4b4b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.handoff-blockers ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.handoff-blockers li {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stat-block {
|
||||
min-width: 0;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-block header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e1e8f0;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.stat-block h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-block header span {
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cleaner-performance-board {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.cleaner-performance-board header > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cleaner-performance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.cleaner-performance-card {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #d8e2ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cleaner-performance-card span,
|
||||
.cleaner-performance-card em,
|
||||
.cleaner-performance-card small {
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cleaner-performance-card strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-tabs__nav-wrap::after {
|
||||
height: 1px;
|
||||
background: #d8e0ea;
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
align-content: center;
|
||||
margin-top: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
grid-template-columns: minmax(0, 1fr) 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.action-board {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.handoff-owner-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.handoff-stage-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cleaner-performance-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.trend-row {
|
||||
grid-template-columns: 92px minmax(80px, 1fr);
|
||||
}
|
||||
|
||||
.trend-meta {
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.handoff-owner-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.handoff-stage-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cleaner-performance-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.handoff-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preflight-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.member-form,
|
||||
.detail-grid,
|
||||
.timeline-grid,
|
||||
.photo-grid,
|
||||
.trend-row,
|
||||
.handoff-session .el-form,
|
||||
.handoff-category-progress,
|
||||
.stats-filter,
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trend-meta {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
export type TaskStatus =
|
||||
| 'WAITING'
|
||||
| 'CLAIMED'
|
||||
| 'STARTED'
|
||||
| 'SUBMITTED'
|
||||
| 'COMPLETED'
|
||||
| 'REJECTED'
|
||||
| 'EXEMPT'
|
||||
| 'SETTLED'
|
||||
| 'CANCELLED';
|
||||
|
||||
export type SettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||
export type PayoutStateFilter = 'NONE' | 'SUCCESS' | 'FAIL' | 'PROCESSING' | 'WAIT_USER_CONFIRM';
|
||||
export type TransferMode = 'API' | 'MOCK';
|
||||
export type UserStatus = 'ACTIVE' | 'DISABLED';
|
||||
export type StaffRole = 'CLEANER' | 'STAFF' | 'STORE_ADMIN' | 'TENANT_ADMIN';
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ManagedUser {
|
||||
id: string;
|
||||
userType: string;
|
||||
status: UserStatus;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
maskedPhone: string;
|
||||
maskedLastIp: string;
|
||||
note: string;
|
||||
roles: StaffRole[];
|
||||
storeIds: string[];
|
||||
wechatMiniappBound: boolean;
|
||||
registeredAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export interface CleaningTask {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
roomNo: string;
|
||||
orderId: string | null;
|
||||
orderNo: string | null;
|
||||
status: TaskStatus;
|
||||
cleanerUserId: string | null;
|
||||
priority: number;
|
||||
rewardCents: number;
|
||||
requirement: string;
|
||||
photoUrls: string[];
|
||||
rejectReason: string;
|
||||
claimedAt?: string;
|
||||
startedAt?: string;
|
||||
submittedAt?: string;
|
||||
completedAt?: string;
|
||||
memberCount: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CleaningTaskMember {
|
||||
id: string;
|
||||
taskId: string;
|
||||
userId: string;
|
||||
nickname: string;
|
||||
memberRole: 'LEAD' | 'ASSIST';
|
||||
rewardCents: number;
|
||||
joinedAt?: string;
|
||||
removedAt?: string | null;
|
||||
settledAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CleaningTaskEvent {
|
||||
id: string;
|
||||
taskId: string;
|
||||
fromStatus: TaskStatus | null;
|
||||
toStatus: TaskStatus;
|
||||
action: string;
|
||||
actorId: string;
|
||||
traceId: string;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CleaningSettlement {
|
||||
id: string;
|
||||
settlementNo: string;
|
||||
cleanerUserId: string;
|
||||
cleanerName: string;
|
||||
storeId: string | null;
|
||||
storeName: string | null;
|
||||
status: SettlementStatus;
|
||||
taskCount: number;
|
||||
totalRewardCents: number;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
paidBy: string | null;
|
||||
confirmedAt: string | null;
|
||||
paidAt: string | null;
|
||||
payoutChannel: string;
|
||||
payoutReference: string;
|
||||
payoutState: string;
|
||||
payoutPackageInfo: string;
|
||||
payoutError: string;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CleaningSettlementItem {
|
||||
id: string;
|
||||
settlementId: string;
|
||||
taskId: string;
|
||||
taskNo: string;
|
||||
orderNo: string | null;
|
||||
storeName: string;
|
||||
roomName: string;
|
||||
roomNo: string;
|
||||
cleanerUserId: string;
|
||||
cleanerName: string;
|
||||
rewardCents: number;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CleaningSettlementDetail {
|
||||
settlement: CleaningSettlement;
|
||||
items: CleaningSettlementItem[];
|
||||
}
|
||||
|
||||
export interface CleaningStatistics {
|
||||
summary: {
|
||||
taskTotal: number;
|
||||
pendingReview: number;
|
||||
active: number;
|
||||
rejected: number;
|
||||
exempted: number;
|
||||
completed: number;
|
||||
rewardCents: number;
|
||||
pendingSettlementCents: number;
|
||||
confirmedSettlementCents: number;
|
||||
paidSettlementCents: number;
|
||||
};
|
||||
byStatus: Array<{
|
||||
status: TaskStatus;
|
||||
total: number;
|
||||
rewardCents: number;
|
||||
}>;
|
||||
byStore: Array<{
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
taskTotal: number;
|
||||
pendingReview: number;
|
||||
completed: number;
|
||||
rejected: number;
|
||||
exempted: number;
|
||||
rewardCents: number;
|
||||
}>;
|
||||
settlements: Array<{
|
||||
status: SettlementStatus;
|
||||
total: number;
|
||||
rewardCents: number;
|
||||
}>;
|
||||
members: Array<{
|
||||
cleanerUserId: string;
|
||||
cleanerName: string;
|
||||
taskCount: number;
|
||||
completedTaskCount: number;
|
||||
rejectedTaskCount: number;
|
||||
pendingSettlementCents: number;
|
||||
settledRewardCents: number;
|
||||
}>;
|
||||
trend: Array<{
|
||||
date: string;
|
||||
taskTotal: number;
|
||||
pendingReview: number;
|
||||
completed: number;
|
||||
rejected: number;
|
||||
exempted: number;
|
||||
rewardCents: number;
|
||||
paidSettlementCents: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type WechatTransferPreflightStatus = 'PASS' | 'WARN' | 'FAIL';
|
||||
|
||||
export interface WechatTransferPreflightCheck {
|
||||
key: string;
|
||||
status: WechatTransferPreflightStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WechatTransferPreflight {
|
||||
ready: boolean;
|
||||
settlement: {
|
||||
id: string;
|
||||
settlementNo: string;
|
||||
status: SettlementStatus;
|
||||
totalRewardCents: number;
|
||||
cleanerUserId: string;
|
||||
storeId: string | null;
|
||||
};
|
||||
account: {
|
||||
configured: boolean;
|
||||
id?: string;
|
||||
storeScoped?: boolean;
|
||||
merchantIdMasked?: string;
|
||||
credentialRefMasked?: string;
|
||||
authorizationStatus?: string;
|
||||
};
|
||||
credential: {
|
||||
configured: boolean;
|
||||
appIdPresent?: boolean;
|
||||
serialNoPresent?: boolean;
|
||||
transferSceneId?: string;
|
||||
reportInfoCount?: number;
|
||||
transferNotifyUrlConfigured?: boolean;
|
||||
platformCertificateCount?: number;
|
||||
};
|
||||
cleaner: {
|
||||
openidConfigured: boolean;
|
||||
};
|
||||
checks: WechatTransferPreflightCheck[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/admin/',
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue'],
|
||||
element: ['element-plus'],
|
||||
icons: ['@lucide/vue']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/admin-api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from './routes/device-control.js';
|
||||
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
|
||||
import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js';
|
||||
import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -72,6 +73,7 @@ export interface BuildAppOptions {
|
||||
deviceControl?: DeviceControlRouteOptions;
|
||||
members?: MemberRouteOptions;
|
||||
recharge?: RechargeRouteOptions;
|
||||
cleaning?: CleaningRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -170,6 +172,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.recharge) {
|
||||
await registerRechargeRoutes(app, options.recharge);
|
||||
}
|
||||
if (options.cleaning) {
|
||||
await registerCleaningRoutes(app, options.cleaning);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -36,13 +36,20 @@ export class RbacRepository {
|
||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p ON
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'CLEANER'
|
||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||
OR (r.code = 'STORE_ADMIN'
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write'))
|
||||
'device.read', 'device.write',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write'))
|
||||
'device.read', 'device.write',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[tenantId, tenantId]
|
||||
);
|
||||
|
||||
@@ -10,11 +10,13 @@ export interface ManagedUser {
|
||||
userType: string;
|
||||
status: string;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
maskedPhone: string;
|
||||
maskedLastIp: string;
|
||||
note: string;
|
||||
roles: string[];
|
||||
storeIds: string[];
|
||||
wechatMiniappBound: boolean;
|
||||
registeredAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
@@ -42,9 +44,11 @@ interface UserRow extends RowDataPacket {
|
||||
userType: string;
|
||||
status: string;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
phone: string;
|
||||
lastIp: string | null;
|
||||
note: string;
|
||||
wechatMiniappBound: number;
|
||||
registeredAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
@@ -66,6 +70,7 @@ export class UserManagementRepository {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
role?: AssignableRole;
|
||||
search?: string;
|
||||
}): Promise<{ items: ManagedUser[]; total: number }> {
|
||||
const filters = ['u.tenant_id = ?', 'u.deleted_at IS NULL'];
|
||||
@@ -74,6 +79,15 @@ export class UserManagementRepository {
|
||||
filters.push('u.status = ?');
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.role) {
|
||||
filters.push(`EXISTS (
|
||||
SELECT 1 FROM qipai_user_roles fur
|
||||
INNER JOIN qipai_roles fr ON fr.tenant_id = fur.tenant_id AND fr.id = fur.role_id
|
||||
WHERE fur.tenant_id = u.tenant_id AND fur.user_id = u.id
|
||||
AND fr.code = ? AND fr.status = 'ACTIVE' AND fr.deleted_at IS NULL
|
||||
)`);
|
||||
params.push(input.role);
|
||||
}
|
||||
if (input.search) {
|
||||
filters.push('(u.nickname LIKE ? OR u.phone LIKE ? OR CAST(u.id AS CHAR) = ?)');
|
||||
const like = `%${input.search}%`;
|
||||
@@ -89,9 +103,15 @@ export class UserManagementRepository {
|
||||
params
|
||||
);
|
||||
const [rows] = await this.pool.execute<UserRow[]>(
|
||||
`SELECT u.id, u.user_type AS userType, u.status, u.nickname, u.phone,
|
||||
`SELECT u.id, u.user_type AS userType, u.status, u.nickname,
|
||||
u.avatar_url AS avatarUrl, u.phone,
|
||||
u.created_at AS registeredAt, u.last_login_at AS lastLoginAt,
|
||||
COALESCE(p.note, '') AS note,
|
||||
EXISTS (
|
||||
SELECT 1 FROM qipai_user_identities i
|
||||
WHERE i.tenant_id = u.tenant_id AND i.user_id = u.id
|
||||
AND i.provider = 'WECHAT_MINIAPP' AND i.deleted_at IS NULL
|
||||
) AS wechatMiniappBound,
|
||||
(SELECT s.ip FROM qipai_auth_sessions s
|
||||
WHERE s.tenant_id = u.tenant_id AND s.user_id = u.id
|
||||
ORDER BY s.created_at DESC LIMIT 1) AS lastIp
|
||||
@@ -107,11 +127,13 @@ export class UserManagementRepository {
|
||||
userType: row.userType,
|
||||
status: row.status,
|
||||
nickname: row.nickname,
|
||||
avatarUrl: row.avatarUrl,
|
||||
maskedPhone: maskPhone(row.phone),
|
||||
maskedLastIp: maskIp(row.lastIp ?? ''),
|
||||
note: row.note,
|
||||
roles: await this.getCodes('role', input.actor.tenantId, String(row.id)),
|
||||
storeIds: await this.getCodes('store', input.actor.tenantId, String(row.id)),
|
||||
wechatMiniappBound: Boolean(row.wechatMiniappBound),
|
||||
registeredAt: row.registeredAt,
|
||||
lastLoginAt: row.lastLoginAt
|
||||
})));
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import {
|
||||
WechatPayClient,
|
||||
WechatPayError,
|
||||
type WechatNotificationHeaders,
|
||||
type WechatPayCredential
|
||||
} from '../payments/wechat-pay-client.js';
|
||||
import {
|
||||
CleaningTaskError,
|
||||
type CleaningActor,
|
||||
type CleaningTaskRepository
|
||||
} from './cleaning-task-repository.js';
|
||||
|
||||
interface SettlementPayoutRow extends RowDataPacket {
|
||||
id: string;
|
||||
settlementNo: string;
|
||||
cleanerUserId: string;
|
||||
storeId: string | null;
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||
totalRewardCents: number;
|
||||
payoutChannel: string;
|
||||
payoutReference: string;
|
||||
payoutState: string;
|
||||
payoutPackageInfo: string;
|
||||
}
|
||||
|
||||
interface AccountRow extends RowDataPacket {
|
||||
id: string;
|
||||
platformAppId: string | null;
|
||||
storeId: string | null;
|
||||
merchantId: string;
|
||||
credentialRef: string;
|
||||
authorizationStatus: string;
|
||||
}
|
||||
|
||||
interface IdentityRow extends RowDataPacket {
|
||||
openid: string;
|
||||
}
|
||||
|
||||
type PreflightStatus = 'PASS' | 'WARN' | 'FAIL';
|
||||
|
||||
interface PreflightCheck {
|
||||
key: string;
|
||||
status: PreflightStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class CleaningPayoutError extends Error {
|
||||
constructor(public readonly code: string, message = code) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CleaningPayoutService {
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly repository: Pick<CleaningTaskRepository,
|
||||
'markSettlementPaid' | 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
|
||||
private readonly client: WechatPayClient,
|
||||
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
|
||||
private readonly mockEnabled: boolean
|
||||
) {}
|
||||
|
||||
async preflightWechatTransfer(input: CleaningActor & { settlementId: string }) {
|
||||
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||
const checks: PreflightCheck[] = [];
|
||||
checks.push({
|
||||
key: 'settlement_status',
|
||||
status: settlement.status === 'CONFIRMED' ? 'PASS' : 'FAIL',
|
||||
message: settlement.status === 'CONFIRMED'
|
||||
? 'Settlement is confirmed and can start WeChat transfer.'
|
||||
: `Settlement status must be CONFIRMED, current status is ${settlement.status}.`
|
||||
});
|
||||
checks.push({
|
||||
key: 'settlement_amount',
|
||||
status: Number(settlement.totalRewardCents) > 0 ? 'PASS' : 'FAIL',
|
||||
message: Number(settlement.totalRewardCents) > 0
|
||||
? 'Settlement amount is greater than zero.'
|
||||
: 'Settlement amount must be greater than zero.'
|
||||
});
|
||||
|
||||
let account: AccountRow | null = null;
|
||||
try {
|
||||
account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||
checks.push({
|
||||
key: 'collection_account',
|
||||
status: 'PASS',
|
||||
message: account.storeId ? 'Store scoped WeChat collection account is configured.'
|
||||
: 'Tenant level WeChat collection account is configured.'
|
||||
});
|
||||
checks.push({
|
||||
key: 'collection_account_authorized',
|
||||
status: account.authorizationStatus === 'AUTHORIZED' ? 'PASS' : 'FAIL',
|
||||
message: account.authorizationStatus === 'AUTHORIZED'
|
||||
? 'Collection account is authorized.'
|
||||
: `Collection account authorization status is ${account.authorizationStatus}.`
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof CleaningPayoutError)) throw error;
|
||||
checks.push({
|
||||
key: 'collection_account',
|
||||
status: 'FAIL',
|
||||
message: 'No enabled WeChat collection account matches this settlement store.'
|
||||
});
|
||||
}
|
||||
|
||||
const credential = account ? this.resolveCredential(account.credentialRef) : null;
|
||||
checks.push({
|
||||
key: 'wechat_credential',
|
||||
status: credential ? 'PASS' : 'FAIL',
|
||||
message: credential ? 'WeChat Pay credential reference is resolvable.'
|
||||
: 'WeChat Pay credential reference is missing or not loaded.'
|
||||
});
|
||||
if (account && credential) {
|
||||
checks.push({
|
||||
key: 'merchant_match',
|
||||
status: credential.merchantId === account.merchantId ? 'PASS' : 'FAIL',
|
||||
message: credential.merchantId === account.merchantId
|
||||
? 'Credential merchant id matches the collection account.'
|
||||
: 'Credential merchant id does not match the collection account.'
|
||||
});
|
||||
checks.push({
|
||||
key: 'transfer_scene_id',
|
||||
status: credential.transferSceneId ? 'PASS' : 'FAIL',
|
||||
message: credential.transferSceneId
|
||||
? 'Merchant transfer scene id is configured.'
|
||||
: 'Merchant transfer scene id is required before real transfer.'
|
||||
});
|
||||
checks.push({
|
||||
key: 'transfer_scene_report_infos',
|
||||
status: credential.transferSceneReportInfos?.length ? 'PASS' : 'WARN',
|
||||
message: credential.transferSceneReportInfos?.length
|
||||
? `${credential.transferSceneReportInfos.length} transfer scene report info item(s) configured.`
|
||||
: 'No transfer scene report info is configured; confirm whether the selected WeChat scene requires it.'
|
||||
});
|
||||
const notifyUrl = credential.transferNotifyUrl || '';
|
||||
checks.push({
|
||||
key: 'transfer_notify_url',
|
||||
status: notifyUrl.startsWith('https://') && notifyUrl.includes('/app-api/cleaning/wechat-transfer/notify')
|
||||
? 'PASS' : 'WARN',
|
||||
message: notifyUrl
|
||||
? 'Transfer notification URL is configured.'
|
||||
: 'Transfer notification URL is not configured; active polling can still sync intermediate states.'
|
||||
});
|
||||
checks.push({
|
||||
key: 'platform_certificates',
|
||||
status: Object.keys(credential.platformCertificates).length > 0 ? 'PASS' : 'FAIL',
|
||||
message: Object.keys(credential.platformCertificates).length > 0
|
||||
? 'At least one WeChat platform certificate is loaded for notification verification.'
|
||||
: 'No WeChat platform certificate is loaded.'
|
||||
});
|
||||
}
|
||||
|
||||
let cleanerOpenidConfigured = false;
|
||||
if (account) {
|
||||
try {
|
||||
await this.resolveCleanerOpenid(input.tenantId, account.platformAppId, settlement.cleanerUserId);
|
||||
cleanerOpenidConfigured = true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof CleaningPayoutError)) throw error;
|
||||
}
|
||||
}
|
||||
checks.push({
|
||||
key: 'cleaner_openid',
|
||||
status: cleanerOpenidConfigured ? 'PASS' : 'FAIL',
|
||||
message: cleanerOpenidConfigured
|
||||
? 'Cleaner has a WeChat miniapp openid for this platform app.'
|
||||
: 'Cleaner WeChat miniapp openid is missing.'
|
||||
});
|
||||
|
||||
return {
|
||||
ready: checks.every((check) => check.status !== 'FAIL'),
|
||||
settlement: {
|
||||
id: settlement.id,
|
||||
settlementNo: settlement.settlementNo,
|
||||
status: settlement.status,
|
||||
totalRewardCents: Number(settlement.totalRewardCents),
|
||||
cleanerUserId: settlement.cleanerUserId,
|
||||
storeId: settlement.storeId
|
||||
},
|
||||
account: account ? {
|
||||
configured: true,
|
||||
id: account.id,
|
||||
storeScoped: account.storeId !== null,
|
||||
merchantIdMasked: maskIdentifier(account.merchantId),
|
||||
credentialRefMasked: maskIdentifier(account.credentialRef),
|
||||
authorizationStatus: account.authorizationStatus
|
||||
} : { configured: false },
|
||||
credential: credential ? {
|
||||
configured: true,
|
||||
appIdPresent: credential.appId.length > 0,
|
||||
serialNoPresent: credential.serialNo.length > 0,
|
||||
transferSceneId: credential.transferSceneId || '',
|
||||
reportInfoCount: credential.transferSceneReportInfos?.length ?? 0,
|
||||
transferNotifyUrlConfigured: Boolean(credential.transferNotifyUrl),
|
||||
platformCertificateCount: Object.keys(credential.platformCertificates).length
|
||||
} : { configured: false },
|
||||
cleaner: { openidConfigured: cleanerOpenidConfigured },
|
||||
checks
|
||||
};
|
||||
}
|
||||
|
||||
async executeWechatTransfer(input: CleaningActor & {
|
||||
settlementId: string;
|
||||
mode: 'API' | 'MOCK';
|
||||
note?: string;
|
||||
}) {
|
||||
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||
if (settlement.status === 'PAID') {
|
||||
return { settlement, idempotent: true, transferState: 'SUCCESS' };
|
||||
}
|
||||
if (settlement.status !== 'CONFIRMED') {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
||||
}
|
||||
if (Number(settlement.totalRewardCents) <= 0) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_AMOUNT_INVALID');
|
||||
}
|
||||
if (input.mode === 'MOCK' && !this.mockEnabled) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MOCK_DISABLED');
|
||||
}
|
||||
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||
if (account.authorizationStatus !== 'AUTHORIZED') {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_ACCOUNT_NOT_AUTHORIZED');
|
||||
}
|
||||
const credential = this.resolveCredential(account.credentialRef);
|
||||
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||
if (credential.merchantId !== account.merchantId) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||
}
|
||||
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
|
||||
if (input.mode === 'MOCK') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER_MOCK',
|
||||
payoutReference: outBillNo,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: 'SUCCESS' };
|
||||
}
|
||||
const openid = await this.resolveCleanerOpenid(
|
||||
input.tenantId,
|
||||
account.platformAppId,
|
||||
settlement.cleanerUserId
|
||||
);
|
||||
const sceneId = credential.transferSceneId;
|
||||
if (!sceneId) throw new CleaningPayoutError('WECHAT_TRANSFER_SCENE_NOT_CONFIGURED');
|
||||
try {
|
||||
const result = await this.client.createMerchantTransfer(credential, {
|
||||
outBillNo,
|
||||
openid,
|
||||
amountCents: Number(settlement.totalRewardCents),
|
||||
remark: `Cleaning settlement ${settlement.id}`,
|
||||
sceneId,
|
||||
notifyUrl: credential.transferNotifyUrl || undefined,
|
||||
reportInfos: credential.transferSceneReportInfos ?? []
|
||||
});
|
||||
if (result.state === 'SUCCESS') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||
}
|
||||
if (result.state === 'FAIL') {
|
||||
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||
}
|
||||
const pending = await this.repository.recordSettlementPayoutPending({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
payoutState: result.state,
|
||||
payoutPackageInfo: result.packageInfo,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: pending, idempotent: false, transferState: result.state };
|
||||
} catch (error) {
|
||||
if (error instanceof WechatPayError) {
|
||||
await this.repository.recordSettlementPayoutFailure({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: outBillNo,
|
||||
error: error.code,
|
||||
note: input.note
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async syncWechatTransfer(input: CleaningActor & { settlementId: string; note?: string }) {
|
||||
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||
if (settlement.status === 'PAID') {
|
||||
return { settlement, idempotent: true, transferState: 'SUCCESS' };
|
||||
}
|
||||
if (settlement.status !== 'CONFIRMED') {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
||||
}
|
||||
if (settlement.payoutChannel !== 'WECHAT_TRANSFER' || !settlement.payoutReference) {
|
||||
throw new CleaningPayoutError('WECHAT_TRANSFER_NOT_STARTED');
|
||||
}
|
||||
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||
const credential = this.resolveCredential(account.credentialRef);
|
||||
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||
if (credential.merchantId !== account.merchantId) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||
}
|
||||
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
|
||||
const result = await this.client.queryMerchantTransferByOutBillNo(credential, outBillNo);
|
||||
if (result.merchantId !== account.merchantId) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||
}
|
||||
if (result.amountCents > 0 && result.amountCents !== Number(settlement.totalRewardCents)) {
|
||||
throw new CleaningPayoutError('WECHAT_TRANSFER_AMOUNT_MISMATCH');
|
||||
}
|
||||
if (result.state === 'SUCCESS') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||
}
|
||||
if (result.state === 'FAIL') {
|
||||
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||
}
|
||||
const pending = await this.repository.recordSettlementPayoutPending({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
payoutState: result.state,
|
||||
payoutPackageInfo: settlement.payoutPackageInfo,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: pending, idempotent: false, transferState: result.state };
|
||||
}
|
||||
|
||||
async processWechatTransferNotification(
|
||||
headers: WechatNotificationHeaders,
|
||||
rawBody: string,
|
||||
traceId: string
|
||||
) {
|
||||
const { credential, payload } = this.decryptNotification(headers, rawBody);
|
||||
const merchantId = stringPayload(payload, 'mch_id');
|
||||
if (merchantId !== credential.merchantId) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||
}
|
||||
const outBillNo = stringPayload(payload, 'out_bill_no');
|
||||
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
|
||||
const settlement = await this.findSettlementByTransferReference(outBillNo, transferBillNo);
|
||||
const actor: CleaningActor = {
|
||||
tenantId: settlement.tenantId,
|
||||
userId: '0',
|
||||
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||
traceId
|
||||
};
|
||||
const state = stringPayload(payload, 'state');
|
||||
if (state === 'SUCCESS') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...actor,
|
||||
settlementId: settlement.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: transferBillNo || outBillNo,
|
||||
note: '微信转账回调确认成功'
|
||||
});
|
||||
return { settlement: paid, transferState: state, idempotent: false };
|
||||
}
|
||||
if (state === 'FAIL') {
|
||||
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||
...actor,
|
||||
settlementId: settlement.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: transferBillNo || outBillNo,
|
||||
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
|
||||
note: '微信转账回调确认失败'
|
||||
});
|
||||
return { settlement: failed, transferState: state, idempotent: false };
|
||||
}
|
||||
const pending = await this.repository.recordSettlementPayoutPending({
|
||||
...actor,
|
||||
settlementId: settlement.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: transferBillNo || outBillNo,
|
||||
payoutState: state,
|
||||
payoutPackageInfo: settlement.payoutPackageInfo,
|
||||
note: '微信转账回调更新中间态'
|
||||
});
|
||||
return { settlement: pending, transferState: state, idempotent: false };
|
||||
}
|
||||
|
||||
private async loadSettlement(tenantId: string, settlementId: string) {
|
||||
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
|
||||
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
|
||||
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
|
||||
payout_channel AS payoutChannel, payout_reference AS payoutReference,
|
||||
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
|
||||
FROM qipai_cleaning_settlements
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
|
||||
[tenantId, settlementId]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async resolveCollectionAccount(tenantId: string, storeId: string | null) {
|
||||
const [rows] = await this.pool.execute<AccountRow[]>(
|
||||
`SELECT id, platform_app_id AS platformAppId, store_id AS storeId,
|
||||
merchant_id AS merchantId, credential_ref AS credentialRef,
|
||||
authorization_status AS authorizationStatus
|
||||
FROM qipai_collection_accounts
|
||||
WHERE tenant_id = ? AND provider = 'WECHAT' AND enabled = 1
|
||||
AND (store_id IS NULL OR store_id <=> ?)
|
||||
ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`,
|
||||
[tenantId, storeId]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningPayoutError('CLEANING_PAYOUT_ACCOUNT_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async resolveCleanerOpenid(tenantId: string, platformAppId: string | null, cleanerUserId: string) {
|
||||
const params: Array<string | number> = [tenantId, cleanerUserId];
|
||||
const platformFilter = platformAppId ? 'AND platform_app_id = ?' : '';
|
||||
if (platformAppId) params.push(platformAppId);
|
||||
const [rows] = await this.pool.execute<IdentityRow[]>(
|
||||
`SELECT openid FROM qipai_user_identities
|
||||
WHERE tenant_id = ? AND user_id = ? AND provider = 'WECHAT_MINIAPP'
|
||||
AND deleted_at IS NULL ${platformFilter}
|
||||
ORDER BY updated_at DESC, id DESC LIMIT 1`,
|
||||
params
|
||||
);
|
||||
if (!rows[0]?.openid) throw new CleaningPayoutError('WECHAT_OPENID_NOT_FOUND');
|
||||
return rows[0].openid;
|
||||
}
|
||||
|
||||
private resolveCredential(reference: string) {
|
||||
return this.credentials.get(reference)
|
||||
?? this.credentials.get(reference.replace(/^env:/, ''));
|
||||
}
|
||||
|
||||
private decryptNotification(headers: WechatNotificationHeaders, rawBody: string) {
|
||||
let lastError: unknown;
|
||||
for (const credential of this.credentials.values()) {
|
||||
if (!credential.platformCertificates[headers.serial]) continue;
|
||||
try {
|
||||
return { credential, payload: this.client.verifyAndDecrypt(credential, headers, rawBody) };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError instanceof Error) throw lastError;
|
||||
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async findSettlementByTransferReference(outBillNo: string, transferBillNo: string) {
|
||||
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
|
||||
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
|
||||
`SELECT tenant_id AS tenantId, id, settlement_no AS settlementNo,
|
||||
cleaner_user_id AS cleanerUserId, store_id AS storeId, status,
|
||||
total_reward_cents AS totalRewardCents, payout_channel AS payoutChannel,
|
||||
payout_reference AS payoutReference, payout_state AS payoutState,
|
||||
payout_package_info AS payoutPackageInfo
|
||||
FROM qipai_cleaning_settlements
|
||||
WHERE payout_channel = 'WECHAT_TRANSFER' AND deleted_at IS NULL
|
||||
AND payout_reference IN (${references.map(() => '?').join(',')})
|
||||
ORDER BY updated_at DESC, id DESC LIMIT 1`,
|
||||
references
|
||||
);
|
||||
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
|
||||
const normalized = settlementNo.replace(/[^A-Za-z0-9_-]/g, '');
|
||||
return (normalized || `CLP${settlementId}`).slice(0, 32);
|
||||
}
|
||||
|
||||
function stringPayload(payload: Record<string, unknown>, key: string) {
|
||||
const value = payload[key];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalStringPayload(payload: Record<string, unknown>, key: string) {
|
||||
const value = payload[key];
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function maskIdentifier(value: string) {
|
||||
if (value.length <= 4) return '****';
|
||||
return `${value.slice(0, 2)}***${value.slice(-4)}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ const configSchema = z.object({
|
||||
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'),
|
||||
QIPAI_PROFIT_SHARE_MOCK_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||
QIPAI_THIRD_PARTY_CREDENTIALS: z.string().default('{}'),
|
||||
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
|
||||
QIPAI_MQTT_CLIENT_ID: z.string().min(1).max(128).default('qipai-backend'),
|
||||
@@ -74,7 +75,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
||||
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
|
||||
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS,
|
||||
profitShareMockEnabled: parsed.NODE_ENV !== 'production'
|
||||
&& parsed.QIPAI_PROFIT_SHARE_MOCK_ENABLED === 'true'
|
||||
&& parsed.QIPAI_PROFIT_SHARE_MOCK_ENABLED === 'true',
|
||||
cleaningPayoutMockEnabled: parsed.NODE_ENV !== 'production'
|
||||
&& parsed.QIPAI_CLEANING_PAYOUT_MOCK_ENABLED === 'true'
|
||||
},
|
||||
thirdParty: {
|
||||
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
|
||||
|
||||
@@ -43,7 +43,13 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062220_m06c_iot_messages.up.sql',
|
||||
'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
|
||||
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
|
||||
'database/migrations/2026062423_m07c_benefits.up.sql'
|
||||
'database/migrations/2026062423_m07c_benefits.up.sql',
|
||||
'database/migrations/2026062524_m08a_recharge_wechat.up.sql',
|
||||
'database/migrations/2026062525_m08b_cleaner_tasks.up.sql',
|
||||
'database/migrations/2026062626_m08b_cleaning_settlements.up.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -68,9 +74,21 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062220_m06c_iot_messages.verify.sql',
|
||||
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
|
||||
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
|
||||
'database/migrations/2026062423_m07c_benefits.verify.sql'
|
||||
'database/migrations/2026062423_m07c_benefits.verify.sql',
|
||||
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql',
|
||||
'database/migrations/2026062525_m08b_cleaner_tasks.verify.sql',
|
||||
'database/migrations/2026062626_m08b_cleaning_settlements.verify.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.down.sql',
|
||||
'database/migrations/2026062626_m08b_cleaning_settlements.down.sql',
|
||||
'database/migrations/2026062525_m08b_cleaner_tasks.down.sql',
|
||||
'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
|
||||
'database/migrations/2026062423_m07c_benefits.down.sql',
|
||||
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
|
||||
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
|
||||
@@ -232,7 +250,12 @@ export async function executeMigrationPlan(
|
||||
3, 3, 9, 1,
|
||||
1, 1, 1, 4, 7, 1,
|
||||
1, 1, 7, 7, 1,
|
||||
5, 10, 7, 3, 1
|
||||
5, 10, 7, 3, 1,
|
||||
2, 8, 4, 3, 1,
|
||||
2, 9, 5, 2, 1,
|
||||
1, 7, 5, 1,
|
||||
4, 1, 1, 1,
|
||||
2, 1, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
|
||||
|
||||
export const orderActions = [
|
||||
'SUBMIT', 'CONFIRM_PAYMENT', 'RESERVE', 'START', 'FINISH', 'CANCEL',
|
||||
@@ -71,7 +72,16 @@ export class OrderStateError extends Error {
|
||||
}
|
||||
|
||||
export class OrderStateRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly benefits?: Pick<MarketingBenefitService, 'releaseReservedInTransaction'>,
|
||||
private readonly cleaningTasks?: {
|
||||
createForFinishedOrder(
|
||||
connection: PoolConnection,
|
||||
input: { tenantId: string; orderId: string; actorId: string; traceId: string }
|
||||
): Promise<void>;
|
||||
}
|
||||
) {}
|
||||
|
||||
async transition(actor: OrderActor, orderId: string, action: OrderAction, reason = '') {
|
||||
return this.transaction(async (connection) => {
|
||||
@@ -99,6 +109,14 @@ export class OrderStateRepository {
|
||||
[targetStatus, nextVersion, actor.tenantId, orderId]
|
||||
);
|
||||
await this.applyReservationState(connection, actor.tenantId, orderId, targetStatus);
|
||||
if (targetStatus === 'FINISHED' && this.cleaningTasks) {
|
||||
await this.cleaningTasks.createForFinishedOrder(connection, {
|
||||
tenantId: actor.tenantId,
|
||||
orderId,
|
||||
actorId: actor.userId,
|
||||
traceId: actor.traceId
|
||||
});
|
||||
}
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_order_status_history
|
||||
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
||||
@@ -223,6 +241,21 @@ export class OrderStateRepository {
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
if (this.benefits) {
|
||||
const [users] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT user_id AS userId FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? ORDER BY id LIMIT 1`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
if (users[0]?.userId) {
|
||||
await this.benefits.releaseReservedInTransaction(connection, {
|
||||
tenantId,
|
||||
userId: String(users[0].userId),
|
||||
orderId,
|
||||
traceId: `order-benefit-release-${orderId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
|
||||
|
||||
export type PricingMode = 'HOURLY' | 'OVERNIGHT' | 'FULL_DAY';
|
||||
|
||||
@@ -18,6 +19,14 @@ export interface QuoteInput {
|
||||
adjustment?: PricingAdjustment;
|
||||
}
|
||||
|
||||
export interface OrderBenefitInput {
|
||||
couponGrantId?: string | null;
|
||||
packageHoldingId?: string | null;
|
||||
packageMinutes?: number;
|
||||
packageCreditCents?: number;
|
||||
clientRequestId?: string;
|
||||
}
|
||||
|
||||
interface RoomPricingRow extends RowDataPacket {
|
||||
id: string;
|
||||
storeId: string;
|
||||
@@ -33,6 +42,7 @@ interface RoomPricingRow extends RowDataPacket {
|
||||
depositCents: number;
|
||||
minimumMinutes: number;
|
||||
maxAdvanceDays: number;
|
||||
roomCategoryId: string | null;
|
||||
}
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
|
||||
@@ -41,7 +51,10 @@ export class PricingError extends Error {
|
||||
}
|
||||
|
||||
export class PricingRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly benefits?: Pick<MarketingBenefitService, 'reserveForOrderInTransaction'>
|
||||
) {}
|
||||
|
||||
async quote(input: QuoteInput) {
|
||||
const room = await this.loadRoom(this.pool, input.tenantId, input.roomId);
|
||||
@@ -55,6 +68,7 @@ export class PricingRepository {
|
||||
userId: string;
|
||||
holdMinutes?: number;
|
||||
allowedStoreIds?: string[] | null;
|
||||
benefits?: OrderBenefitInput | null;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
|
||||
@@ -66,7 +80,7 @@ export class PricingRepository {
|
||||
const isHoliday = await this.isHoliday(
|
||||
connection, input.tenantId, input.startAt, room.timezone
|
||||
);
|
||||
const quote = this.calculate(room, input, isHoliday);
|
||||
let quote = this.calculate(room, input, isHoliday);
|
||||
const holdMinutes = Math.min(Math.max(input.holdMinutes ?? 15, 5), 30);
|
||||
const orderNo = `QP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
|
||||
const [orderResult] = await connection.execute<ResultSetHeader>(
|
||||
@@ -79,6 +93,44 @@ export class PricingRepository {
|
||||
holdMinutes, quote.totalCents]
|
||||
);
|
||||
const orderId = String(orderResult.insertId);
|
||||
let benefitReservation = null;
|
||||
const requestedBenefits = input.benefits;
|
||||
if (this.benefits && requestedBenefits
|
||||
&& (requestedBenefits.couponGrantId || requestedBenefits.packageHoldingId)) {
|
||||
if (!requestedBenefits.clientRequestId) {
|
||||
throw new PricingError('BENEFIT_CLIENT_REQUEST_ID_REQUIRED');
|
||||
}
|
||||
benefitReservation = await this.benefits.reserveForOrderInTransaction(connection, {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
orderId,
|
||||
storeId: String(room.storeId),
|
||||
roomId: input.roomId,
|
||||
roomCategoryId: room.roomCategoryId,
|
||||
orderAmountCents: quote.totalCents,
|
||||
orderStartAt: input.startAt,
|
||||
isHoliday,
|
||||
couponGrantId: requestedBenefits.couponGrantId ?? null,
|
||||
packageHoldingId: requestedBenefits.packageHoldingId ?? null,
|
||||
packageMinutes: requestedBenefits.packageMinutes,
|
||||
packageCreditCents: requestedBenefits.packageCreditCents,
|
||||
clientRequestId: requestedBenefits.clientRequestId,
|
||||
traceId: requestedBenefits.clientRequestId
|
||||
});
|
||||
quote = this.calculate(room, {
|
||||
...input,
|
||||
adjustment: {
|
||||
discountCents: benefitReservation.discountCents
|
||||
+ timeCouponDiscountCents(benefitReservation.minutes, quote.unitPriceCents),
|
||||
packageCreditCents: benefitReservation.packageCreditCents
|
||||
}
|
||||
}, isHoliday);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders SET total_amount_cents = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[quote.totalCents, input.tenantId, orderId]
|
||||
);
|
||||
}
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_order_price_snapshots
|
||||
(tenant_id, order_id, pricing_mode, duration_minutes, unit_price_cents,
|
||||
@@ -115,7 +167,8 @@ export class PricingRepository {
|
||||
storeId: String(room.storeId),
|
||||
reservationId: String(reservationResult.insertId),
|
||||
holdMinutes,
|
||||
quote
|
||||
quote,
|
||||
benefitReservation
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -250,7 +303,8 @@ export class PricingRepository {
|
||||
r.minimum_spend_cents AS minimumSpendCents,
|
||||
r.deposit_cents AS depositCents,
|
||||
r.minimum_minutes AS minimumMinutes,
|
||||
r.max_advance_days AS maxAdvanceDays
|
||||
r.max_advance_days AS maxAdvanceDays,
|
||||
r.room_category_id AS roomCategoryId
|
||||
FROM qipai_rooms r
|
||||
INNER JOIN qipai_stores s
|
||||
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
|
||||
@@ -320,6 +374,11 @@ function clampAdjustment(value: number | undefined, maximum: number) {
|
||||
return Math.min(Math.trunc(value), maximum);
|
||||
}
|
||||
|
||||
function timeCouponDiscountCents(minutes: number | undefined, unitPriceCents: number) {
|
||||
if (!minutes || minutes <= 0) return 0;
|
||||
return Math.ceil(minutes / 60) * unitPriceCents;
|
||||
}
|
||||
|
||||
function localDateKey(date: Date, timezone: string) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
|
||||
import type { WalletLedgerService } from '../wallets/wallet-ledger-service.js';
|
||||
|
||||
export type PaymentProvider = 'WECHAT' | 'BALANCE' | 'PACKAGE' | 'GROUP_BUY' | 'TEST';
|
||||
|
||||
@@ -31,7 +33,11 @@ export class PaymentError extends Error {
|
||||
}
|
||||
|
||||
export class PaymentRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly wallet?: Pick<WalletLedgerService, 'debitInTransaction'>,
|
||||
private readonly benefits?: Pick<MarketingBenefitService, 'confirmReservedInTransaction'>
|
||||
) {}
|
||||
|
||||
async createPayment(input: {
|
||||
tenantId: string;
|
||||
@@ -54,7 +60,7 @@ export class PaymentRepository {
|
||||
if (input.provider === 'TEST' && !input.testAdapterEnabled) {
|
||||
throw new PaymentError('TEST_PAYMENT_DISABLED');
|
||||
}
|
||||
if (input.provider !== 'TEST') {
|
||||
if (!['TEST', 'BALANCE'].includes(input.provider)) {
|
||||
await this.resolveConfig(
|
||||
connection, input.tenantId, input.platformAppId, order.storeId, input.provider
|
||||
);
|
||||
@@ -94,6 +100,36 @@ export class PaymentRepository {
|
||||
[input.tenantId, paymentId, input.provider, amountCents,
|
||||
input.provider === 'TEST' ? 'test' : 'configured']
|
||||
);
|
||||
if (input.provider === 'BALANCE') {
|
||||
if (!this.wallet) throw new PaymentError('WALLET_SETTLEMENT_NOT_CONFIGURED');
|
||||
await this.wallet.debitInTransaction(connection, {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
scopeType: 'STORE',
|
||||
storeId: order.storeId,
|
||||
businessType: 'ORDER_PAYMENT',
|
||||
businessId: input.orderId,
|
||||
entryType: 'CONSUME',
|
||||
amountCents,
|
||||
traceId: input.clientRequestId,
|
||||
note: 'Customer balance payment',
|
||||
metadata: { paymentId, provider: input.provider }
|
||||
});
|
||||
await this.applyPaymentSuccess(connection, {
|
||||
paymentId,
|
||||
orderId: input.orderId,
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
amountCents,
|
||||
providerPaymentId: `BAL-${paymentNo}`,
|
||||
traceId: input.clientRequestId,
|
||||
reason: 'Balance payment completed'
|
||||
});
|
||||
return this.paymentResponse({
|
||||
id: paymentId, orderId: input.orderId, paymentNo, provider: input.provider,
|
||||
status: 'SUCCEEDED', amountCents
|
||||
} as PaymentRow, false, input.testAdapterEnabled);
|
||||
}
|
||||
return this.paymentResponse({
|
||||
id: paymentId, orderId: input.orderId, paymentNo, provider: input.provider,
|
||||
status: 'PENDING', amountCents
|
||||
@@ -150,45 +186,16 @@ export class PaymentRepository {
|
||||
);
|
||||
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: true };
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_payments
|
||||
SET status = 'SUCCEEDED', provider_payment_id = ?,
|
||||
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
|
||||
[`TEST-${input.callbackId}`, input.tenantId, input.paymentId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET paid_amount_cents = paid_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[payment.amountCents, input.tenantId, payment.orderId]
|
||||
);
|
||||
const order = await this.loadOrder(connection, input.tenantId, payment.orderId, true);
|
||||
if (Number(order.paidAmountCents) >= Number(order.totalAmountCents)
|
||||
&& order.status === 'PENDING_PAYMENT') {
|
||||
const nextVersion = Number((order as OrderRow & { statusVersion?: number }).statusVersion ?? 1) + 1;
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders SET status = 'PAID', status_version = ?,
|
||||
status_updated_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[nextVersion, input.tenantId, payment.orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations
|
||||
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
|
||||
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
|
||||
[input.tenantId, payment.orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_order_status_history
|
||||
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
||||
actor_id, source, reason, trace_id, metadata)
|
||||
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
|
||||
NULL, 'PAYMENT', 'Verified payment callback', ?,
|
||||
JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
|
||||
[input.tenantId, payment.orderId, input.traceId, nextVersion, input.paymentId]
|
||||
);
|
||||
}
|
||||
await this.applyPaymentSuccess(connection, {
|
||||
paymentId: input.paymentId,
|
||||
orderId: payment.orderId,
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
amountCents: Number(payment.amountCents),
|
||||
providerPaymentId: `TEST-${input.callbackId}`,
|
||||
traceId: input.traceId,
|
||||
reason: 'Verified payment callback'
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_payment_callbacks
|
||||
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
|
||||
@@ -288,6 +295,67 @@ export class PaymentRepository {
|
||||
if (!rows[0]) throw new PaymentError('ORDER_ACCESS_FORBIDDEN');
|
||||
}
|
||||
|
||||
async applyPaymentSuccess(
|
||||
connection: PoolConnection,
|
||||
input: {
|
||||
paymentId: string;
|
||||
orderId: string;
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
amountCents: number;
|
||||
providerPaymentId: string;
|
||||
traceId: string;
|
||||
reason: string;
|
||||
}
|
||||
) {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_payments
|
||||
SET status = 'SUCCEEDED', provider_payment_id = ?,
|
||||
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
|
||||
[input.providerPaymentId, input.tenantId, input.paymentId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET paid_amount_cents = paid_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.amountCents, input.tenantId, input.orderId]
|
||||
);
|
||||
const order = await this.loadOrder(connection, input.tenantId, input.orderId, true);
|
||||
if (Number(order.paidAmountCents) >= Number(order.totalAmountCents)
|
||||
&& order.status === 'PENDING_PAYMENT') {
|
||||
if (this.benefits && input.userId) {
|
||||
await this.benefits.confirmReservedInTransaction(connection, {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
orderId: input.orderId,
|
||||
traceId: input.traceId
|
||||
});
|
||||
}
|
||||
const nextVersion = Number((order as OrderRow & { statusVersion?: number }).statusVersion ?? 1) + 1;
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders SET status = 'PAID', status_version = ?,
|
||||
status_updated_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[nextVersion, input.tenantId, input.orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations
|
||||
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
|
||||
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
|
||||
[input.tenantId, input.orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_order_status_history
|
||||
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
||||
actor_id, source, reason, trace_id, metadata)
|
||||
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
|
||||
NULL, 'PAYMENT', ?, ?, JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
|
||||
[input.tenantId, input.orderId, input.reason, input.traceId, nextVersion, input.paymentId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface WechatPayCredential {
|
||||
apiV3Key: string;
|
||||
platformCertificates: Record<string, string>;
|
||||
profitShareReceivers?: Record<string, string>;
|
||||
transferSceneId?: string;
|
||||
transferSceneReportInfos?: Array<{ infoType: string; infoContent: string }>;
|
||||
transferNotifyUrl?: string;
|
||||
}
|
||||
|
||||
export interface WechatPayTransport {
|
||||
@@ -173,6 +176,56 @@ export class WechatPayClient {
|
||||
});
|
||||
}
|
||||
|
||||
async createMerchantTransfer(
|
||||
credential: WechatPayCredential,
|
||||
input: {
|
||||
outBillNo: string;
|
||||
openid: string;
|
||||
amountCents: number;
|
||||
remark: string;
|
||||
sceneId: string;
|
||||
notifyUrl?: string;
|
||||
reportInfos: Array<{ infoType: string; infoContent: string }>;
|
||||
}
|
||||
) {
|
||||
const result = await this.apiRequest(credential, 'POST', '/v3/fund-app/mch-transfer/transfer-bills', {
|
||||
appid: credential.appId,
|
||||
out_bill_no: input.outBillNo,
|
||||
transfer_scene_id: input.sceneId,
|
||||
openid: input.openid,
|
||||
transfer_amount: input.amountCents,
|
||||
transfer_remark: input.remark.slice(0, 32),
|
||||
notify_url: input.notifyUrl,
|
||||
transfer_scene_report_infos: input.reportInfos.length > 0 ? input.reportInfos.map((item) => ({
|
||||
info_type: item.infoType.slice(0, 15),
|
||||
info_content: item.infoContent.slice(0, 32)
|
||||
})) : undefined
|
||||
});
|
||||
return {
|
||||
outBillNo: stringField(result, 'out_bill_no'),
|
||||
transferBillNo: optionalStringField(result, 'transfer_bill_no'),
|
||||
state: stringField(result, 'state'),
|
||||
failReason: optionalStringField(result, 'fail_reason'),
|
||||
packageInfo: optionalStringField(result, 'package_info')
|
||||
};
|
||||
}
|
||||
|
||||
async queryMerchantTransferByOutBillNo(credential: WechatPayCredential, outBillNo: string) {
|
||||
const result = await this.apiRequest(
|
||||
credential,
|
||||
'GET',
|
||||
`/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/${encodeURIComponent(outBillNo)}`
|
||||
);
|
||||
return {
|
||||
merchantId: stringField(result, 'mch_id'),
|
||||
outBillNo: stringField(result, 'out_bill_no'),
|
||||
transferBillNo: optionalStringField(result, 'transfer_bill_no'),
|
||||
state: stringField(result, 'state'),
|
||||
failReason: optionalStringField(result, 'fail_reason'),
|
||||
amountCents: optionalNumberField(result, 'transfer_amount')
|
||||
};
|
||||
}
|
||||
|
||||
verifyAndDecrypt(
|
||||
credential: WechatPayCredential,
|
||||
headers: WechatNotificationHeaders,
|
||||
@@ -279,7 +332,10 @@ export function parseWechatPayCredentials(value: string) {
|
||||
}
|
||||
return [reference, account];
|
||||
})
|
||||
) : {}
|
||||
) : {},
|
||||
transferSceneId: optionalStringField(item, 'transferSceneId'),
|
||||
transferSceneReportInfos: parseTransferReportInfos(item.transferSceneReportInfos),
|
||||
transferNotifyUrl: optionalStringField(item, 'transferNotifyUrl')
|
||||
});
|
||||
}
|
||||
return credentials;
|
||||
@@ -327,3 +383,22 @@ function optionalStringField(value: Record<string, unknown>, key: string) {
|
||||
const field = value[key];
|
||||
return typeof field === 'string' ? field : '';
|
||||
}
|
||||
|
||||
function optionalNumberField(value: Record<string, unknown>, key: string) {
|
||||
const field = value[key];
|
||||
return typeof field === 'number' ? field : 0;
|
||||
}
|
||||
|
||||
function parseTransferReportInfos(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.map((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new WechatPayError('WECHAT_TRANSFER_REPORT_INVALID');
|
||||
}
|
||||
const raw = item as Record<string, unknown>;
|
||||
return {
|
||||
infoType: stringField(raw, 'infoType'),
|
||||
infoContent: stringField(raw, 'infoContent')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface PaymentRow extends RowDataPacket {
|
||||
paidAmountCents: number;
|
||||
totalAmountCents: number;
|
||||
orderStatus: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface RefundRow extends RowDataPacket {
|
||||
@@ -419,44 +420,17 @@ export class WechatPaymentService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_payments
|
||||
SET status = 'SUCCEEDED', provider_payment_id = ?,
|
||||
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
|
||||
[transactionId, payment.tenantId, payment.id]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders SET paid_amount_cents = paid_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[payment.amountCents, payment.tenantId, payment.orderId]
|
||||
);
|
||||
const nextPaid = Number(payment.paidAmountCents) + Number(payment.amountCents);
|
||||
if (nextPaid >= Number(payment.totalAmountCents)
|
||||
&& payment.orderStatus === 'PENDING_PAYMENT') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET status = 'PAID', status_version = status_version + 1,
|
||||
status_updated_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[payment.tenantId, payment.orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations
|
||||
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
|
||||
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
|
||||
[payment.tenantId, payment.orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_order_status_history
|
||||
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
||||
actor_id, source, reason, trace_id, metadata)
|
||||
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
|
||||
NULL, 'PAYMENT', 'Verified Wechat payment callback', ?,
|
||||
JSON_OBJECT('paymentId', ?))`,
|
||||
[payment.tenantId, payment.orderId, traceId, payment.id]
|
||||
);
|
||||
}
|
||||
const userId = await this.loadOrderUserId(connection, payment.tenantId, payment.orderId);
|
||||
await this.paymentRepository.applyPaymentSuccess(connection, {
|
||||
paymentId: payment.id,
|
||||
orderId: payment.orderId,
|
||||
tenantId: payment.tenantId,
|
||||
userId,
|
||||
amountCents: Number(payment.amountCents),
|
||||
providerPaymentId: transactionId,
|
||||
traceId,
|
||||
reason: 'Verified Wechat payment callback'
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_payment_callbacks
|
||||
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
|
||||
@@ -465,6 +439,16 @@ export class WechatPaymentService {
|
||||
);
|
||||
}
|
||||
|
||||
private async loadOrderUserId(connection: PoolConnection, tenantId: string, orderId: string) {
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT user_id AS userId FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? AND revoked_at IS NULL
|
||||
ORDER BY id LIMIT 1`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
return rows[0]?.userId ? String(rows[0].userId) : undefined;
|
||||
}
|
||||
|
||||
private async rejectCallback(
|
||||
connection: PoolConnection, tenantId: string, callbackId: string, code: string
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
import { Transform } from 'node:stream';
|
||||
import type {
|
||||
FastifyInstance, FastifyReply, FastifyRequest, preParsingHookHandler
|
||||
} from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import { MediaStorage, MediaValidationError } from '../content/media-storage.js';
|
||||
import {
|
||||
CleaningTaskError,
|
||||
cleaningTaskStatuses,
|
||||
type CleaningActor,
|
||||
type CleaningTaskRepository
|
||||
} from '../cleaning/cleaning-task-repository.js';
|
||||
import {
|
||||
CleaningPayoutError,
|
||||
type CleaningPayoutService
|
||||
} from '../cleaning/cleaning-payout-service.js';
|
||||
import { WechatPayError, type WechatNotificationHeaders } from '../payments/wechat-pay-client.js';
|
||||
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(50).default(20),
|
||||
status: z.enum(cleaningTaskStatuses).optional()
|
||||
});
|
||||
const managerTaskListSchema = listSchema.extend({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
}).strict();
|
||||
const paramsSchema = z.object({ taskId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const submitSchema = z.object({
|
||||
photoUrls: z.array(z.string().url()).max(9).default([]),
|
||||
note: z.string().trim().max(512).optional()
|
||||
});
|
||||
const assignSchema = z.object({
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const memberParamsSchema = z.object({
|
||||
taskId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/)
|
||||
});
|
||||
const memberSchema = z.object({
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
rewardCents: z.coerce.number().int().min(0).max(1000000),
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const memberRemoveSchema = 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 exemptSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
|
||||
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
|
||||
const settlementPayoutStateSchema = z.enum([
|
||||
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM'
|
||||
]);
|
||||
const settlementListSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(50).default(20),
|
||||
status: settlementStatusSchema.optional(),
|
||||
payoutState: settlementPayoutStateSchema.optional(),
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
}).strict();
|
||||
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const settlementGenerateSchema = z.object({
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const settlementConfirmSchema = z.object({
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const settlementPaidSchema = z.object({
|
||||
payoutChannel: z.string().trim().min(1).max(32),
|
||||
payoutReference: z.string().trim().min(1).max(128),
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const settlementPayoutFailureSchema = z.object({
|
||||
payoutChannel: z.string().trim().max(32).optional(),
|
||||
payoutReference: z.string().trim().max(128).optional(),
|
||||
error: z.string().trim().min(1).max(512),
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const settlementWechatTransferSchema = z.object({
|
||||
mode: z.enum(['API', 'MOCK']).default('API'),
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const settlementWechatSyncSchema = z.object({
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const statisticsSchema = z.object({
|
||||
from: z.string().regex(/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/).optional(),
|
||||
to: z.string().regex(/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/).optional(),
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
}).strict();
|
||||
const reclaimSchema = z.object({
|
||||
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||
});
|
||||
|
||||
export interface CleaningRouteOptions {
|
||||
repository: Pick<CleaningTaskRepository,
|
||||
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
||||
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
|
||||
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
|
||||
| 'assertCanUploadPhoto' | 'stats' | 'managerStatistics'>;
|
||||
mediaStorage?: MediaStorage;
|
||||
payoutService?: Pick<CleaningPayoutService,
|
||||
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
|
||||
| 'processWechatTransferNotification'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerCleaningRoutes(
|
||||
app: FastifyInstance,
|
||||
options: CleaningRouteOptions
|
||||
): Promise<void> {
|
||||
if (options.mediaStorage && !app.hasContentTypeParser('application/octet-stream')) {
|
||||
app.addContentTypeParser(
|
||||
'application/octet-stream',
|
||||
{ parseAs: 'buffer', bodyLimit: 8 * 1024 * 1024 },
|
||||
(_request, body, done) => done(null, body)
|
||||
);
|
||||
}
|
||||
|
||||
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listHall({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/cleaning/tasks/mine', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listMine({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/cleaning/stats', 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.stats(actor),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/tasks', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = managerTaskListSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listManage({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/statistics', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = statisticsSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = managerTaskListSchema.omit({ status: true }).safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.settlementCandidates({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/settlements', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = settlementListSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listSettlements({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getSettlementDetail({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
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.claim({ ...actor, taskId: params.data.taskId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/assign', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = assignSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.assign({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
cleanerUserId: body.data.cleanerUserId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/tasks/:taskId/members', 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.listMembers({ ...actor, taskId: params.data.taskId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/tasks/:taskId/events', 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.listEvents({ ...actor, taskId: params.data.taskId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = memberSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.addMember({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
cleanerUserId: body.data.cleanerUserId,
|
||||
rewardCents: body.data.rewardCents,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/members/:cleanerUserId/remove', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = memberParamsSchema.safeParse(request.params);
|
||||
const body = memberRemoveSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.removeMember({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
cleanerUserId: params.data.cleanerUserId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
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.start({ ...actor, taskId: params.data.taskId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/cleaning/tasks/:taskId/rework', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
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.rework({ ...actor, taskId: params.data.taskId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/cleaning/tasks/:taskId/photos', async (request, reply) => {
|
||||
if (!options.mediaStorage) return reply.status(501).send({
|
||||
code: 'CLEANING_PHOTO_UPLOAD_UNAVAILABLE',
|
||||
message: 'Cleaning photo upload is not configured.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const originalName = singleHeader(request.headers['x-file-name']);
|
||||
if (!params.success || !originalName || !Buffer.isBuffer(request.body)) {
|
||||
return invalid(reply, request.traceId);
|
||||
}
|
||||
return handle(reply, request.traceId, async () => {
|
||||
await options.repository.assertCanUploadPhoto({ ...actor, taskId: params.data.taskId });
|
||||
const image = await options.mediaStorage!.storeImage({
|
||||
tenantId: actor.tenantId,
|
||||
originalName,
|
||||
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
|
||||
body: request.body as Buffer
|
||||
});
|
||||
return reply.status(201).send({ code: 0, data: image, traceId: request.traceId });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/app-api/cleaning/tasks/:taskId/submit', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = submitSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.submit({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
photoUrls: body.data.photoUrls,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/complete', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = completeSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.complete({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/reject', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = rejectSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.reject({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
reason: body.data.reason
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/exempt', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = exemptSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.exempt({
|
||||
...actor,
|
||||
taskId: params.data.taskId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const body = reclaimSchema.safeParse(request.body ?? {});
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.reclaimTimeouts({ ...actor, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const body = settlementGenerateSchema.safeParse(request.body ?? {});
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.generateSettlement({ ...actor, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/confirm', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
const body = settlementConfirmSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.confirmSettlement({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
const body = settlementPaidSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.markSettlementPaid({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId,
|
||||
payoutChannel: body.data.payoutChannel,
|
||||
payoutReference: body.data.payoutReference,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/payout-failure', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
const body = settlementPayoutFailureSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.recordSettlementPayoutFailure({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId,
|
||||
payoutChannel: body.data.payoutChannel,
|
||||
payoutReference: body.data.payoutReference,
|
||||
error: body.data.error,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/wechat-transfer', async (request, reply) => {
|
||||
if (!options.payoutService) return reply.status(501).send({
|
||||
code: 'CLEANING_PAYOUT_UNAVAILABLE',
|
||||
message: 'Cleaning payout service is not configured.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
const body = settlementWechatTransferSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.payoutService!.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId,
|
||||
mode: body.data.mode,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/settlements/:settlementId/wechat-transfer/preflight', async (request, reply) => {
|
||||
if (!options.payoutService) return reply.status(501).send({
|
||||
code: 'CLEANING_PAYOUT_UNAVAILABLE',
|
||||
message: 'Cleaning payout service is not configured.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.payoutService!.preflightWechatTransfer({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/wechat-transfer/sync', async (request, reply) => {
|
||||
if (!options.payoutService) return reply.status(501).send({
|
||||
code: 'CLEANING_PAYOUT_UNAVAILABLE',
|
||||
message: 'Cleaning payout service is not configured.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
const body = settlementWechatSyncSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.payoutService!.syncWechatTransfer({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/cleaning/wechat-transfer/notify', {
|
||||
preParsing: captureRawBody
|
||||
}, async (request, reply) => {
|
||||
if (!options.payoutService) return reply.status(501).send({
|
||||
code: 'CLEANING_PAYOUT_UNAVAILABLE',
|
||||
message: 'Cleaning payout service is not configured.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
const headers = wechatHeaders(request);
|
||||
if (!headers) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => {
|
||||
await options.payoutService!.processWechatTransferNotification(
|
||||
headers,
|
||||
request.rawBody,
|
||||
request.traceId
|
||||
);
|
||||
return { code: 'SUCCESS', message: '成功', traceId: request.traceId };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActor(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: CleaningRouteOptions,
|
||||
mode: 'read' | 'write'
|
||||
): Promise<CleaningActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization,
|
||||
options.authRepository,
|
||||
options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
|
||||
if (!access.capabilities.includes(permission)
|
||||
&& !access.capabilities.includes('tenant.manage')
|
||||
&& !access.roles.includes('PLATFORM_ADMIN')) {
|
||||
reply.status(403).send({
|
||||
code: 'CLEANING_TASK_FORBIDDEN',
|
||||
message: 'Cleaning task permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
traceId: request.traceId
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (error instanceof MediaValidationError) {
|
||||
return reply.status(400).send({
|
||||
code: error.code,
|
||||
message: 'The cleaning task request cannot be completed.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
if (!(error instanceof CleaningTaskError)
|
||||
&& !(error instanceof CleaningPayoutError)
|
||||
&& !(error instanceof WechatPayError)) throw error;
|
||||
const code = error.code;
|
||||
const statusCode = code === 'CLEANING_TASK_FORBIDDEN'
|
||||
|| code === 'CLEANING_SETTLEMENT_FORBIDDEN' ? 403 : 409;
|
||||
return reply.status(statusCode).send({
|
||||
code,
|
||||
message: 'The cleaning task request cannot be completed.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function singleHeader(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_CLEANING_TASK_REQUEST',
|
||||
message: 'The cleaning task request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
|
||||
function wechatHeaders(request: FastifyRequest): WechatNotificationHeaders | null {
|
||||
const read = (key: string) => singleHeader(request.headers[key]);
|
||||
const headers = {
|
||||
timestamp: read('wechatpay-timestamp'),
|
||||
nonce: read('wechatpay-nonce'),
|
||||
serial: read('wechatpay-serial'),
|
||||
signature: read('wechatpay-signature')
|
||||
};
|
||||
return headers.timestamp && headers.nonce && headers.serial && headers.signature
|
||||
? headers as WechatNotificationHeaders
|
||||
: null;
|
||||
}
|
||||
|
||||
const captureRawBody: preParsingHookHandler = (request, _reply, payload, done) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const capture = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
callback(null, chunk);
|
||||
},
|
||||
flush(callback) {
|
||||
request.rawBody = Buffer.concat(chunks).toString('utf8');
|
||||
callback();
|
||||
}
|
||||
});
|
||||
const transformed = payload.pipe(capture) as typeof payload;
|
||||
transformed.receivedEncodedLength = payload.receivedEncodedLength;
|
||||
done(null, transformed);
|
||||
};
|
||||
@@ -9,7 +9,14 @@ const requestSchema = z.object({
|
||||
roomId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
startAt: z.coerce.date(),
|
||||
endAt: z.coerce.date(),
|
||||
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY')
|
||||
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY'),
|
||||
benefits: z.object({
|
||||
couponGrantId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
|
||||
packageHoldingId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
|
||||
packageMinutes: z.number().int().min(0).optional(),
|
||||
packageCreditCents: z.number().int().min(0).optional(),
|
||||
clientRequestId: z.string().min(8).max(128)
|
||||
}).strict().optional()
|
||||
}).refine((value) => value.endAt > value.startAt);
|
||||
const adminReserveSchema = requestSchema.and(z.object({
|
||||
userId: z.string().regex(/^[1-9]\d{0,19}$/)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply, preParsingHookHandler } from 'fastify';
|
||||
import { Transform } from 'node:stream';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import { RechargeError, type RechargeService } from '../wallets/recharge-service.js';
|
||||
import { WechatPayError } from '../payments/wechat-pay-client.js';
|
||||
|
||||
const listSchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
@@ -13,9 +15,15 @@ const createSchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
|
||||
clientRequestId: z.string().min(8).max(128)
|
||||
}).strict();
|
||||
const orderParams = z.object({
|
||||
rechargeOrderId: z.string().regex(/^[1-9]\d{0,19}$/)
|
||||
});
|
||||
|
||||
export interface RechargeRouteOptions {
|
||||
service: Pick<RechargeService, 'listAvailablePlans' | 'createRechargeOrder'>;
|
||||
service: Pick<
|
||||
RechargeService,
|
||||
'listAvailablePlans' | 'createRechargeOrder' | 'createWechatPrepay' | 'processWechatNotification'
|
||||
>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
@@ -58,6 +66,36 @@ export async function registerRechargeRoutes(
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/recharge/orders/:rechargeOrderId/wechat-prepay', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.createWechatPrepay({
|
||||
tenantId: auth.tenantId,
|
||||
platformAppId: auth.platformAppId,
|
||||
userId: auth.userId,
|
||||
rechargeOrderId: params.data.rechargeOrderId
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/recharge/wechat/notify', {
|
||||
preParsing: captureRawBody
|
||||
}, async (request, reply) => {
|
||||
return handle(reply, request.traceId, async () => {
|
||||
const data = await options.service.processWechatNotification(
|
||||
notificationHeaders(request.headers),
|
||||
request.rawBody,
|
||||
request.traceId
|
||||
);
|
||||
return reply.send({ code: 'SUCCESS', message: '成功', data });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
@@ -77,6 +115,7 @@ async function authenticate(
|
||||
if (!access.capabilities.includes('profile.read')) return null;
|
||||
return {
|
||||
tenantId: result.session.tenantId,
|
||||
platformAppId: result.session.platformAppId,
|
||||
userId: result.session.user.id
|
||||
};
|
||||
}
|
||||
@@ -85,11 +124,12 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof RechargeError)) throw error;
|
||||
if (!(error instanceof RechargeError) && !(error instanceof WechatPayError)) throw error;
|
||||
const status = error.code === 'RECHARGE_PLAN_NOT_FOUND'
|
||||
|| error.code === 'RECHARGE_ORDER_NOT_FOUND'
|
||||
? 404
|
||||
: error.code === 'RECHARGE_LIMIT_REACHED' ? 409 : 400;
|
||||
: error.code === 'RECHARGE_LIMIT_REACHED' ? 409
|
||||
: error.code.includes('FORBIDDEN') ? 403 : 400;
|
||||
return reply.status(status).send({
|
||||
code: error.code,
|
||||
message: 'The recharge request is not available.',
|
||||
@@ -98,6 +138,39 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
||||
}
|
||||
}
|
||||
|
||||
const captureRawBody: preParsingHookHandler = (request, _reply, payload, done) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const capture = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
callback(null, chunk);
|
||||
},
|
||||
flush(callback) {
|
||||
request.rawBody = Buffer.concat(chunks).toString('utf8');
|
||||
callback();
|
||||
}
|
||||
});
|
||||
const transformed = payload.pipe(capture) as typeof payload;
|
||||
transformed.receivedEncodedLength = payload.receivedEncodedLength;
|
||||
done(null, transformed);
|
||||
};
|
||||
|
||||
function notificationHeaders(headers: Record<string, unknown>) {
|
||||
const read = (name: string) => {
|
||||
const value = headers[name];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new WechatPayError('WECHAT_NOTIFICATION_HEADER_INVALID');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
timestamp: read('wechatpay-timestamp'),
|
||||
nonce: read('wechatpay-nonce'),
|
||||
serial: read('wechatpay-serial'),
|
||||
signature: read('wechatpay-signature')
|
||||
};
|
||||
}
|
||||
|
||||
function unauthorized(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID',
|
||||
|
||||
@@ -32,6 +32,7 @@ const listSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
|
||||
role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(),
|
||||
search: z.string().trim().max(128).optional()
|
||||
});
|
||||
|
||||
|
||||
+28
-4
@@ -36,16 +36,28 @@ import { DeviceControlService } from './devices/device-control-service.js';
|
||||
import { MemberProfileService } from './wallets/member-profile-service.js';
|
||||
import { RechargeService } from './wallets/recharge-service.js';
|
||||
import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
|
||||
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
|
||||
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
|
||||
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
const authRepository = new AuthRepository(pool);
|
||||
const accessControl = new RbacRepository(pool);
|
||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||
const paymentRepository = new PaymentRepository(pool);
|
||||
const walletLedgerService = new WalletLedgerService(pool);
|
||||
const marketingBenefits = new MarketingBenefitService(pool);
|
||||
const cleaningTaskRepository = new CleaningTaskRepository(pool);
|
||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||
const cleaningPayoutService = new CleaningPayoutService(
|
||||
pool,
|
||||
cleaningTaskRepository,
|
||||
wechatPayClient,
|
||||
wechatCredentials,
|
||||
config.payment.cleaningPayoutMockEnabled
|
||||
);
|
||||
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
|
||||
const iotMessages = new IotMessageService(pool);
|
||||
const mqtt = new MqttService(config.mqtt, undefined, (topic, payload) =>
|
||||
@@ -94,13 +106,13 @@ const app = await buildApp({
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
pricing: {
|
||||
repository: new PricingRepository(pool),
|
||||
repository: new PricingRepository(pool, marketingBenefits),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
orderState: {
|
||||
repository: new OrderStateRepository(pool),
|
||||
repository: new OrderStateRepository(pool, marketingBenefits, cleaningTaskRepository),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
@@ -174,7 +186,19 @@ const app = await buildApp({
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
recharge: {
|
||||
service: new RechargeService(pool, walletLedgerService),
|
||||
service: new RechargeService(pool, walletLedgerService, {
|
||||
paymentRepository,
|
||||
client: wechatPayClient,
|
||||
credentials: wechatCredentials
|
||||
}),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
cleaning: {
|
||||
repository: cleaningTaskRepository,
|
||||
payoutService: cleaningPayoutService,
|
||||
mediaStorage: new MediaStorage(resolve(process.cwd(), 'shared', 'uploads')),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
|
||||
@@ -67,6 +67,14 @@ export class MarketingBenefitService {
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
return this.reserveForOrderInTransaction(connection, input);
|
||||
});
|
||||
}
|
||||
|
||||
async reserveForOrderInTransaction(
|
||||
connection: PoolConnection,
|
||||
input: Parameters<MarketingBenefitService['reserveForOrder']>[0]
|
||||
) {
|
||||
const duplicate = await this.findUsageByRequest(connection, input);
|
||||
if (duplicate.length > 0) return reserveResponse(duplicate, true);
|
||||
|
||||
@@ -130,7 +138,6 @@ export class MarketingBenefitService {
|
||||
throw new MarketingBenefitError('BENEFIT_EMPTY');
|
||||
}
|
||||
return reserveResponse(usages, false);
|
||||
});
|
||||
}
|
||||
|
||||
async confirmReserved(input: {
|
||||
@@ -140,8 +147,16 @@ export class MarketingBenefitService {
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
return this.confirmReservedInTransaction(connection, input);
|
||||
});
|
||||
}
|
||||
|
||||
async confirmReservedInTransaction(
|
||||
connection: PoolConnection,
|
||||
input: Parameters<MarketingBenefitService['confirmReserved']>[0]
|
||||
) {
|
||||
const usages = await this.loadOrderUsages(connection, input, true);
|
||||
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
|
||||
if (usages.length === 0) return reserveResponse([], true);
|
||||
if (usages.every((usage) => usage.status === 'CONFIRMED')) {
|
||||
return reserveResponse(usages, true);
|
||||
}
|
||||
@@ -181,7 +196,6 @@ export class MarketingBenefitService {
|
||||
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'CONFIRMED');
|
||||
}
|
||||
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'CONFIRMED' })), false);
|
||||
});
|
||||
}
|
||||
|
||||
async releaseReserved(input: {
|
||||
@@ -191,15 +205,21 @@ export class MarketingBenefitService {
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
return this.releaseReservedInTransaction(connection, input);
|
||||
});
|
||||
}
|
||||
|
||||
async releaseReservedInTransaction(
|
||||
connection: PoolConnection,
|
||||
input: Parameters<MarketingBenefitService['releaseReserved']>[0]
|
||||
) {
|
||||
const usages = await this.loadOrderUsages(connection, input, true);
|
||||
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
|
||||
if (usages.length === 0) return reserveResponse([], true);
|
||||
if (usages.every((usage) => usage.status === 'RELEASED')) {
|
||||
return reserveResponse(usages, true);
|
||||
}
|
||||
for (const usage of usages) {
|
||||
if (usage.status !== 'FROZEN') {
|
||||
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
|
||||
}
|
||||
if (usage.status !== 'FROZEN') continue;
|
||||
if (usage.benefitType === 'COUPON') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
@@ -217,8 +237,9 @@ export class MarketingBenefitService {
|
||||
}
|
||||
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'RELEASED');
|
||||
}
|
||||
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'RELEASED' })), false);
|
||||
});
|
||||
return reserveResponse(usages.map((usage) => (
|
||||
usage.status === 'FROZEN' ? { ...usage, status: 'RELEASED' } : usage
|
||||
)), false);
|
||||
}
|
||||
|
||||
private async loadCoupon(
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { PaymentRepository } from '../payments/payment-repository.js';
|
||||
import {
|
||||
WechatPayClient,
|
||||
WechatPayError,
|
||||
type WechatNotificationHeaders,
|
||||
type WechatPayCredential
|
||||
} from '../payments/wechat-pay-client.js';
|
||||
import type { WalletLedgerService } from './wallet-ledger-service.js';
|
||||
|
||||
interface PlanRow extends RowDataPacket {
|
||||
@@ -26,6 +33,7 @@ interface RechargeOrderRow extends RowDataPacket {
|
||||
payAmountCents: number;
|
||||
giftAmountCents: number;
|
||||
status: string;
|
||||
providerPaymentId?: string | null;
|
||||
}
|
||||
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
@@ -37,7 +45,12 @@ export class RechargeError extends Error {
|
||||
export class RechargeService {
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly wallet: Pick<WalletLedgerService, 'credit'>
|
||||
private readonly wallet: Pick<WalletLedgerService, 'credit'>,
|
||||
private readonly wechat?: {
|
||||
paymentRepository: Pick<PaymentRepository, 'resolveConfig'>;
|
||||
client: WechatPayClient;
|
||||
credentials: ReadonlyMap<string, WechatPayCredential>;
|
||||
}
|
||||
) {}
|
||||
|
||||
async listAvailablePlans(input: {
|
||||
@@ -158,6 +171,113 @@ export class RechargeService {
|
||||
});
|
||||
}
|
||||
|
||||
async createWechatPrepay(input: {
|
||||
tenantId: string;
|
||||
platformAppId: string;
|
||||
userId: string;
|
||||
rechargeOrderId: string;
|
||||
}) {
|
||||
if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED');
|
||||
const order = await this.loadOwnedRechargeOrder(input, false);
|
||||
if (order.status === 'CREDITED') throw new RechargeError('RECHARGE_ALREADY_CREDITED');
|
||||
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
|
||||
throw new RechargeError('RECHARGE_STATUS_INVALID');
|
||||
}
|
||||
const config = await this.wechat.paymentRepository.resolveConfig(
|
||||
this.pool,
|
||||
input.tenantId,
|
||||
input.platformAppId,
|
||||
order.storeId ?? '0',
|
||||
'WECHAT'
|
||||
);
|
||||
const credential = this.resolveWechatCredential(config.credentialRef);
|
||||
const settings = config.settings as Record<string, unknown>;
|
||||
const [identityRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT openid FROM qipai_user_identities
|
||||
WHERE tenant_id = ? AND platform_app_id = ? AND user_id = ?
|
||||
AND provider = 'WECHAT' LIMIT 1`,
|
||||
[input.tenantId, input.platformAppId, input.userId]
|
||||
);
|
||||
if (!identityRows[0]?.openid) throw new WechatPayError('WECHAT_OPENID_NOT_FOUND');
|
||||
const result = await this.wechat.client.createJsapiPrepay(credential, {
|
||||
description: typeof settings.rechargeDescription === 'string'
|
||||
? settings.rechargeDescription : `棋牌室余额充值 ${order.rechargeNo}`,
|
||||
outTradeNo: order.rechargeNo,
|
||||
notifyUrl: settingUrl(
|
||||
settings, 'rechargeNotifyUrl', 'https://api.txyundm.cn/app-api/recharge/wechat/notify'
|
||||
),
|
||||
amountCents: Number(order.payAmountCents),
|
||||
payerOpenId: String(identityRows[0].openid)
|
||||
});
|
||||
await this.pool.execute(
|
||||
`UPDATE qipai_recharge_orders
|
||||
SET payment_provider = 'WECHAT', provider_prepay_id = ?
|
||||
WHERE tenant_id = ? AND id = ? AND user_id = ?`,
|
||||
[result.prepayId, input.tenantId, input.rechargeOrderId, input.userId]
|
||||
);
|
||||
return {
|
||||
rechargeOrderId: input.rechargeOrderId,
|
||||
rechargeNo: order.rechargeNo,
|
||||
amountCents: Number(order.payAmountCents),
|
||||
...result.paymentParams
|
||||
};
|
||||
}
|
||||
|
||||
async processWechatNotification(
|
||||
headers: WechatNotificationHeaders,
|
||||
rawBody: string,
|
||||
traceId: string
|
||||
) {
|
||||
if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED');
|
||||
const data = this.verifyWithConfiguredCredential(headers, rawBody);
|
||||
const rechargeNo = requiredString(data, 'out_trade_no');
|
||||
const transactionId = requiredString(data, 'transaction_id');
|
||||
const tradeState = requiredString(data, 'trade_state');
|
||||
const amount = objectValue(data.amount);
|
||||
const total = requiredNumber(amount, 'total');
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadRechargeOrderByNo(connection, rechargeNo, true);
|
||||
const callbackId = `${headers.serial}:${transactionId}:${tradeState}`;
|
||||
const [callback] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO qipai_payment_callbacks
|
||||
(tenant_id, payment_id, provider, callback_id, callback_type,
|
||||
verified, payload)
|
||||
VALUES (?, NULL, 'WECHAT_RECHARGE', ?, 'PAYMENT_NOTIFICATION', 1, CAST(? AS JSON))`,
|
||||
[order.tenantId, callbackId, JSON.stringify(redactNotification(data))]
|
||||
);
|
||||
if (callback.affectedRows === 0) {
|
||||
return { rechargeOrderId: order.id, status: order.status, idempotent: true };
|
||||
}
|
||||
if (tradeState !== 'SUCCESS' || total !== Number(order.payAmountCents)) {
|
||||
const code = tradeState !== 'SUCCESS'
|
||||
? `WECHAT_TRADE_${tradeState}` : 'PAYMENT_AMOUNT_MISMATCH';
|
||||
await connection.execute(
|
||||
`UPDATE qipai_payment_callbacks
|
||||
SET processing_status = 'REJECTED', error_code = ?,
|
||||
processed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`,
|
||||
[code, order.tenantId, callbackId]
|
||||
);
|
||||
return { rechargeOrderId: order.id, status: 'REJECTED', code, idempotent: false };
|
||||
}
|
||||
const credited = await this.creditRechargeOrder(connection, {
|
||||
tenantId: String(order.tenantId),
|
||||
rechargeOrderId: String(order.id),
|
||||
providerTransactionId: transactionId,
|
||||
providerCallbackId: callbackId,
|
||||
rawNotify: redactNotification(data),
|
||||
traceId
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_payment_callbacks
|
||||
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`,
|
||||
[order.tenantId, callbackId]
|
||||
);
|
||||
return credited;
|
||||
});
|
||||
}
|
||||
|
||||
private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) {
|
||||
const [rows] = await connection.execute<PlanRow[]>(
|
||||
`SELECT id, tenant_id AS tenantId, store_id AS storeId, name,
|
||||
@@ -217,6 +337,128 @@ export class RechargeService {
|
||||
};
|
||||
}
|
||||
|
||||
private async loadOwnedRechargeOrder(
|
||||
input: { tenantId: string; userId: string; rechargeOrderId: string },
|
||||
lock: boolean
|
||||
) {
|
||||
const [rows] = await this.pool.execute<RechargeOrderRow[]>(
|
||||
`SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId,
|
||||
recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents,
|
||||
gift_amount_cents AS giftAmountCents, status,
|
||||
provider_payment_id AS providerPaymentId
|
||||
FROM qipai_recharge_orders
|
||||
WHERE tenant_id = ? AND id = ? AND user_id = ?
|
||||
${lock ? 'FOR UPDATE' : ''}`,
|
||||
[input.tenantId, input.rechargeOrderId, input.userId]
|
||||
);
|
||||
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
|
||||
return normalizeRechargeOrder(rows[0]);
|
||||
}
|
||||
|
||||
private async loadRechargeOrderByNo(
|
||||
connection: PoolConnection,
|
||||
rechargeNo: string,
|
||||
lock: boolean
|
||||
) {
|
||||
const [rows] = await connection.execute<Array<RechargeOrderRow & { tenantId: string }>>(
|
||||
`SELECT id, tenant_id AS tenantId, user_id AS userId,
|
||||
store_id AS storeId, plan_id AS planId, recharge_no AS rechargeNo,
|
||||
pay_amount_cents AS payAmountCents,
|
||||
gift_amount_cents AS giftAmountCents, status,
|
||||
provider_payment_id AS providerPaymentId
|
||||
FROM qipai_recharge_orders
|
||||
WHERE recharge_no = ?
|
||||
${lock ? 'FOR UPDATE' : ''}`,
|
||||
[rechargeNo]
|
||||
);
|
||||
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
|
||||
return { ...normalizeRechargeOrder(rows[0]), tenantId: String(rows[0].tenantId) };
|
||||
}
|
||||
|
||||
private async creditRechargeOrder(
|
||||
connection: PoolConnection,
|
||||
input: {
|
||||
tenantId: string;
|
||||
rechargeOrderId: string;
|
||||
providerTransactionId: string;
|
||||
providerCallbackId: string;
|
||||
rawNotify: Record<string, unknown>;
|
||||
traceId: string;
|
||||
}
|
||||
) {
|
||||
const order = await this.loadRechargeOrder(connection, input, true);
|
||||
if (order.status === 'CREDITED') {
|
||||
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: true };
|
||||
}
|
||||
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
|
||||
throw new RechargeError('RECHARGE_STATUS_INVALID');
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_recharge_orders
|
||||
SET status = 'PAID',
|
||||
provider_payment_id = ?,
|
||||
provider_callback_id = ?,
|
||||
raw_notify = CAST(? AS JSON),
|
||||
paid_at = COALESCE(paid_at, UTC_TIMESTAMP(3))
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[
|
||||
input.providerTransactionId,
|
||||
input.providerCallbackId,
|
||||
JSON.stringify(input.rawNotify),
|
||||
input.tenantId,
|
||||
order.id
|
||||
]
|
||||
);
|
||||
const plan = await this.loadPlan(connection, input.tenantId, order.planId);
|
||||
await this.wallet.credit({
|
||||
tenantId: input.tenantId,
|
||||
userId: String(order.userId),
|
||||
scopeType: plan.scopeType,
|
||||
storeId: plan.scopeType === 'STORE' ? order.storeId : null,
|
||||
businessType: 'RECHARGE',
|
||||
businessId: order.id,
|
||||
entryType: 'RECHARGE',
|
||||
cashDeltaCents: Number(order.payAmountCents),
|
||||
giftDeltaCents: Number(order.giftAmountCents),
|
||||
traceId: input.traceId,
|
||||
metadata: {
|
||||
provider: 'WECHAT',
|
||||
providerTransactionId: input.providerTransactionId,
|
||||
rechargeNo: order.rechargeNo
|
||||
}
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_recharge_orders
|
||||
SET status = 'CREDITED', credited_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, order.id]
|
||||
);
|
||||
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: false };
|
||||
}
|
||||
|
||||
private resolveWechatCredential(reference: string) {
|
||||
const credential = this.wechat?.credentials.get(reference)
|
||||
?? this.wechat?.credentials.get(reference.replace(/^env:/, ''));
|
||||
if (!credential) throw new WechatPayError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||
return credential;
|
||||
}
|
||||
|
||||
private verifyWithConfiguredCredential(
|
||||
headers: WechatNotificationHeaders,
|
||||
rawBody: string
|
||||
) {
|
||||
let lastError: unknown;
|
||||
for (const credential of this.wechat?.credentials.values() ?? []) {
|
||||
if (!credential.platformCertificates[headers.serial]) continue;
|
||||
try {
|
||||
return this.wechat!.client.verifyAndDecrypt(credential, headers, rawBody);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async assertPurchaseLimit(
|
||||
connection: PoolConnection,
|
||||
tenantId: string,
|
||||
@@ -276,3 +518,52 @@ function rechargeResponse(row: RechargeOrderRow, idempotent: boolean) {
|
||||
idempotent
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRechargeOrder(row: RechargeOrderRow): RechargeOrderRow {
|
||||
return {
|
||||
...row,
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
storeId: row.storeId === null ? null : String(row.storeId),
|
||||
planId: String(row.planId),
|
||||
payAmountCents: Number(row.payAmountCents),
|
||||
giftAmountCents: Number(row.giftAmountCents)
|
||||
};
|
||||
}
|
||||
|
||||
function settingUrl(
|
||||
settings: Record<string, unknown>, key: string, fallback: string
|
||||
) {
|
||||
const value = settings[key];
|
||||
return typeof value === 'string' && value.startsWith('https://') ? value : fallback;
|
||||
}
|
||||
|
||||
function requiredString(value: Record<string, unknown>, key: string) {
|
||||
const field = value[key];
|
||||
if (typeof field !== 'string' || field.length === 0) {
|
||||
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
function requiredNumber(value: Record<string, unknown>, key: string) {
|
||||
const field = value[key];
|
||||
if (typeof field !== 'number' || !Number.isInteger(field)) {
|
||||
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function redactNotification(value: Record<string, unknown>) {
|
||||
const copy = { ...value };
|
||||
delete copy.payer;
|
||||
delete copy.user_received_account;
|
||||
return copy;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ export class WalletLedgerService {
|
||||
|
||||
async debit(input: WalletMutationInput & { amountCents: number }) {
|
||||
return this.transaction(async (connection) => {
|
||||
return this.debitInTransaction(connection, input);
|
||||
});
|
||||
}
|
||||
|
||||
async debitInTransaction(
|
||||
connection: PoolConnection,
|
||||
input: WalletMutationInput & { amountCents: number }
|
||||
) {
|
||||
const account = await this.ensureAccount(connection, input, true);
|
||||
const duplicate = await this.findDuplicate(connection, account, input);
|
||||
if (duplicate) return ledgerResponse(account, duplicate, true);
|
||||
@@ -58,7 +66,6 @@ export class WalletLedgerService {
|
||||
throw new WalletLedgerError('WALLET_BALANCE_INSUFFICIENT');
|
||||
}
|
||||
return this.applyDelta(connection, account, input, -cashUsed, -giftUsed, false);
|
||||
});
|
||||
}
|
||||
|
||||
async adjust(input: WalletMutationInput & {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import {
|
||||
CleaningPayoutError,
|
||||
CleaningPayoutService
|
||||
} from '../dist/cleaning/cleaning-payout-service.js';
|
||||
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
QIPAI_MQTT_USERNAME: 'backend-test',
|
||||
QIPAI_MQTT_PASSWORD: 'not-a-real-secret',
|
||||
QIPAI_JWT_SECRET: 'production-cleaning-payout-secret-long-enough',
|
||||
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: 'true'
|
||||
}).payment.cleaningPayoutMockEnabled, false);
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: 'true'
|
||||
}).payment.cleaningPayoutMockEnabled, true);
|
||||
|
||||
const actor = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||
traceId: 'cleaning-payout-test'
|
||||
};
|
||||
|
||||
{
|
||||
const harness = createHarness({});
|
||||
const result = await harness.service.preflightWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501'
|
||||
});
|
||||
assert.equal(result.ready, true);
|
||||
assert.equal(result.account.merchantIdMasked, '19***0109');
|
||||
assert.equal(result.credential.reportInfoCount, 1);
|
||||
assert.equal(result.cleaner.openidConfigured, true);
|
||||
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_id').status, 'PASS');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({
|
||||
credentialPatch: { transferSceneId: '', transferSceneReportInfos: [] },
|
||||
openid: ''
|
||||
});
|
||||
const result = await harness.service.preflightWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501'
|
||||
});
|
||||
assert.equal(result.ready, false);
|
||||
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_id').status, 'FAIL');
|
||||
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_report_infos').status, 'WARN');
|
||||
assert.equal(result.checks.find((item) => item.key === 'cleaner_openid').status, 'FAIL');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'SUCCESS' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
|
||||
assert.equal(harness.state.transferInput.amountCents, 1200);
|
||||
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'WAIT_USER_CONFIRM', packageInfo: 'package-info' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API',
|
||||
note: 'need user confirm'
|
||||
});
|
||||
assert.equal(result.transferState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(harness.repository.pending[0].payoutState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(harness.repository.pending[0].payoutPackageInfo, 'package-info');
|
||||
assert.equal(harness.repository.paid.length, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ queryState: 'SUCCESS' });
|
||||
const result = await harness.service.syncWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
note: 'poll success'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.state.queryOutBillNo, 'CLS-20260627-501');
|
||||
assert.equal(harness.repository.paid[0].payoutReference, 'wx-transfer-query-501');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ queryState: 'FAIL', queryFailReason: 'ACCOUNT_ABNORMAL' });
|
||||
const result = await harness.service.syncWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501'
|
||||
});
|
||||
assert.equal(result.transferState, 'FAIL');
|
||||
assert.equal(harness.repository.failures[0].error, 'ACCOUNT_ABNORMAL');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ queryState: 'PROCESSING' });
|
||||
const result = await harness.service.syncWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501'
|
||||
});
|
||||
assert.equal(result.transferState, 'PROCESSING');
|
||||
assert.equal(harness.repository.pending[0].payoutState, 'PROCESSING');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({
|
||||
notificationPayload: {
|
||||
mch_id: '1900000109',
|
||||
out_bill_no: 'CLS-20260627-501',
|
||||
transfer_bill_no: 'wx-notify-501',
|
||||
state: 'SUCCESS'
|
||||
}
|
||||
});
|
||||
const result = await harness.service.processWechatTransferNotification({
|
||||
timestamp: '1782700000',
|
||||
nonce: 'notify-nonce',
|
||||
serial: 'PLATFORM-SERIAL',
|
||||
signature: 'signature'
|
||||
}, '{"resource":"encrypted"}', 'notify-trace');
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.state.verifiedNotification.rawBody, '{"resource":"encrypted"}');
|
||||
assert.equal(harness.repository.paid[0].tenantId, '7');
|
||||
assert.equal(harness.repository.paid[0].payoutReference, 'wx-notify-501');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API'
|
||||
});
|
||||
assert.equal(result.transferState, 'FAIL');
|
||||
assert.equal(harness.repository.failures[0].error, 'REAL_NAME_CHECK_FAILED');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ mockEnabled: true });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'MOCK'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER_MOCK');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ mockEnabled: false });
|
||||
await assert.rejects(
|
||||
() => harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'MOCK'
|
||||
}),
|
||||
(error) => error instanceof CleaningPayoutError
|
||||
&& error.code === 'CLEANING_PAYOUT_MOCK_DISABLED'
|
||||
);
|
||||
}
|
||||
|
||||
function createHarness(options = {}) {
|
||||
const state = {
|
||||
transferInput: null,
|
||||
queryOutBillNo: null,
|
||||
settlement: {
|
||||
id: '501',
|
||||
settlementNo: 'CLS-20260627-501',
|
||||
cleanerUserId: '31',
|
||||
storeId: '11',
|
||||
status: options.status ?? 'CONFIRMED',
|
||||
totalRewardCents: 1200,
|
||||
payoutChannel: options.payoutChannel ?? 'WECHAT_TRANSFER',
|
||||
payoutReference: options.payoutReference ?? 'CLS-20260627-501',
|
||||
payoutState: options.payoutState ?? 'WAIT_USER_CONFIRM',
|
||||
payoutPackageInfo: options.payoutPackageInfo ?? 'package-info'
|
||||
},
|
||||
account: {
|
||||
id: '41',
|
||||
platformAppId: '9',
|
||||
storeId: '11',
|
||||
merchantId: '1900000109',
|
||||
credentialRef: 'wechat-cleaning',
|
||||
authorizationStatus: options.authorizationStatus ?? 'AUTHORIZED'
|
||||
}
|
||||
};
|
||||
const pool = {
|
||||
async execute(sql) {
|
||||
if (sql.includes("payout_channel = 'WECHAT_TRANSFER'")) {
|
||||
return [[{ ...state.settlement, tenantId: '7' }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_cleaning_settlements')) {
|
||||
return [[state.settlement], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_collection_accounts')) {
|
||||
return [[state.account], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_user_identities')) {
|
||||
return [options.openid === '' ? [] : [{ openid: options.openid ?? 'openid-cleaner' }], []];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
};
|
||||
const repository = {
|
||||
paid: [],
|
||||
failures: [],
|
||||
pending: [],
|
||||
async markSettlementPaid(input) {
|
||||
this.paid.push(input);
|
||||
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
|
||||
},
|
||||
async recordSettlementPayoutFailure(input) {
|
||||
this.failures.push(input);
|
||||
return { ...state.settlement, payoutError: input.error };
|
||||
},
|
||||
async recordSettlementPayoutPending(input) {
|
||||
this.pending.push(input);
|
||||
return { ...state.settlement, payoutState: input.payoutState };
|
||||
}
|
||||
};
|
||||
const client = {
|
||||
async createMerchantTransfer(_credential, input) {
|
||||
state.transferInput = input;
|
||||
return {
|
||||
outBillNo: input.outBillNo,
|
||||
transferBillNo: 'wx-transfer-501',
|
||||
state: options.transferState ?? 'SUCCESS',
|
||||
failReason: options.failReason ?? '',
|
||||
packageInfo: options.packageInfo ?? ''
|
||||
};
|
||||
},
|
||||
async queryMerchantTransferByOutBillNo(_credential, outBillNo) {
|
||||
state.queryOutBillNo = outBillNo;
|
||||
return {
|
||||
merchantId: options.queryMerchantId ?? '1900000109',
|
||||
outBillNo,
|
||||
transferBillNo: 'wx-transfer-query-501',
|
||||
state: options.queryState ?? 'SUCCESS',
|
||||
failReason: options.queryFailReason ?? '',
|
||||
amountCents: options.queryAmountCents ?? 1200
|
||||
};
|
||||
},
|
||||
verifyAndDecrypt(_credential, headers, rawBody) {
|
||||
state.verifiedNotification = { headers, rawBody };
|
||||
return options.notificationPayload ?? {};
|
||||
}
|
||||
};
|
||||
const credential = {
|
||||
appId: 'wx-test',
|
||||
merchantId: '1900000109',
|
||||
serialNo: 'serial',
|
||||
privateKeyPem: 'private-key',
|
||||
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||
platformCertificates: { 'PLATFORM-SERIAL': 'certificate' },
|
||||
transferSceneId: '1000',
|
||||
transferSceneReportInfos: [{ infoType: '岗位类型', infoContent: '保洁员' }]
|
||||
};
|
||||
Object.assign(credential, options.credentialPatch ?? {});
|
||||
return {
|
||||
state,
|
||||
repository,
|
||||
service: new CleaningPayoutService(
|
||||
pool,
|
||||
repository,
|
||||
client,
|
||||
new Map([['wechat-cleaning', credential]]),
|
||||
options.mockEnabled ?? false
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
console.log('PASS: M08-B cleaning payout service handles WeChat transfer states and mock gate.');
|
||||
@@ -0,0 +1,712 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
|
||||
const secret = 'test-only-cleaning-route-secret';
|
||||
const token = signAccessToken({
|
||||
sub: '31', sid: '9c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const forbiddenToken = signAccessToken({
|
||||
sub: '32', sid: '8c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
|
||||
const calls = [];
|
||||
const app = await buildApp({
|
||||
cleaning: {
|
||||
jwtSecret: secret,
|
||||
authRepository: {
|
||||
async validateSession(sessionId) {
|
||||
return {
|
||||
id: sessionId,
|
||||
tenantId: '7',
|
||||
platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
user: {
|
||||
id: sessionId.startsWith('8') ? '32' : '31',
|
||||
tenantId: '7',
|
||||
userType: 'CUSTOMER',
|
||||
status: 'ACTIVE',
|
||||
roleVersion: 1,
|
||||
nickname: '',
|
||||
avatarUrl: '',
|
||||
phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: {
|
||||
async getAccessProfile(tenantId, userId) {
|
||||
return userId === '31'
|
||||
? {
|
||||
roles: ['CLEANER'],
|
||||
capabilities: [
|
||||
'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read',
|
||||
'cleaning.settlement.read', 'cleaning.settlement.write'
|
||||
],
|
||||
storeIds: ['11']
|
||||
}
|
||||
: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] };
|
||||
}
|
||||
},
|
||||
repository: {
|
||||
async listHall(input) {
|
||||
calls.push(['listHall', input]);
|
||||
return { items: [task('WAITING')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async listMine(input) {
|
||||
calls.push(['listMine', input]);
|
||||
return { items: [task('CLAIMED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async listManage(input) {
|
||||
calls.push(['listManage', input]);
|
||||
return { items: [task(input.status || 'SUBMITTED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async claim(input) {
|
||||
calls.push(['claim', input]);
|
||||
return task('CLAIMED');
|
||||
},
|
||||
async start(input) {
|
||||
calls.push(['start', input]);
|
||||
return task('STARTED');
|
||||
},
|
||||
async rework(input) {
|
||||
calls.push(['rework', input]);
|
||||
return task('STARTED');
|
||||
},
|
||||
async assertCanUploadPhoto(input) {
|
||||
calls.push(['assertCanUploadPhoto', input]);
|
||||
},
|
||||
async submit(input) {
|
||||
calls.push(['submit', input]);
|
||||
return { ...task('SUBMITTED'), photoUrls: input.photoUrls };
|
||||
},
|
||||
async assign(input) {
|
||||
calls.push(['assign', input]);
|
||||
return { ...task('CLAIMED'), cleanerUserId: input.cleanerUserId };
|
||||
},
|
||||
async complete(input) {
|
||||
calls.push(['complete', input]);
|
||||
return task('COMPLETED');
|
||||
},
|
||||
async reject(input) {
|
||||
calls.push(['reject', input]);
|
||||
return { ...task('REJECTED'), rejectReason: input.reason };
|
||||
},
|
||||
async exempt(input) {
|
||||
calls.push(['exempt', input]);
|
||||
return { ...task('EXEMPT'), rewardCents: 0 };
|
||||
},
|
||||
async listMembers(input) {
|
||||
calls.push(['listMembers', input]);
|
||||
return [member('LEAD', '31', 500), member('ASSIST', '33', 100)];
|
||||
},
|
||||
async listEvents(input) {
|
||||
calls.push(['listEvents', input]);
|
||||
return [{
|
||||
id: '9001',
|
||||
taskId: input.taskId,
|
||||
fromStatus: 'STARTED',
|
||||
toStatus: 'SUBMITTED',
|
||||
action: 'SUBMIT',
|
||||
actorId: input.userId,
|
||||
traceId: input.traceId,
|
||||
note: 'done',
|
||||
createdAt: new Date()
|
||||
}];
|
||||
},
|
||||
async addMember(input) {
|
||||
calls.push(['addMember', input]);
|
||||
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
|
||||
},
|
||||
async removeMember(input) {
|
||||
calls.push(['removeMember', input]);
|
||||
return [member('LEAD', '31', 600)];
|
||||
},
|
||||
async settlementCandidates(input) {
|
||||
calls.push(['settlementCandidates', input]);
|
||||
return { items: [task('COMPLETED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async listSettlements(input) {
|
||||
calls.push(['listSettlements', input]);
|
||||
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async getSettlementDetail(input) {
|
||||
calls.push(['getSettlementDetail', input]);
|
||||
return {
|
||||
settlement: settlementResponse('DRAFT'),
|
||||
items: [settlementItemResponse()]
|
||||
};
|
||||
},
|
||||
async generateSettlement(input) {
|
||||
calls.push(['generateSettlement', input]);
|
||||
return settlementResponse('DRAFT');
|
||||
},
|
||||
async confirmSettlement(input) {
|
||||
calls.push(['confirmSettlement', input]);
|
||||
return settlementResponse('CONFIRMED');
|
||||
},
|
||||
async markSettlementPaid(input) {
|
||||
calls.push(['markSettlementPaid', input]);
|
||||
return {
|
||||
...settlementResponse('PAID'),
|
||||
paidBy: input.userId,
|
||||
paidAt: new Date(),
|
||||
payoutChannel: input.payoutChannel,
|
||||
payoutReference: input.payoutReference,
|
||||
payoutError: ''
|
||||
};
|
||||
},
|
||||
async recordSettlementPayoutFailure(input) {
|
||||
calls.push(['recordSettlementPayoutFailure', input]);
|
||||
return {
|
||||
...settlementResponse('CONFIRMED'),
|
||||
payoutChannel: input.payoutChannel || '',
|
||||
payoutReference: input.payoutReference || '',
|
||||
payoutError: input.error
|
||||
};
|
||||
},
|
||||
async reclaimTimeouts(input) {
|
||||
calls.push(['reclaimTimeouts', input]);
|
||||
return { reclaimed: 2, taskIds: ['101', '102'] };
|
||||
},
|
||||
async stats(input) {
|
||||
calls.push(['stats', input]);
|
||||
return {
|
||||
byStatus: { SUBMITTED: 2, COMPLETED: 1, REJECTED: 1 },
|
||||
taskTotal: 4,
|
||||
completed: 1,
|
||||
rejected: 1,
|
||||
completionRate: 25,
|
||||
pendingSettlementCents: 1200,
|
||||
settledRewardCents: 600
|
||||
};
|
||||
},
|
||||
async managerStatistics(input) {
|
||||
calls.push(['managerStatistics', input]);
|
||||
return {
|
||||
summary: {
|
||||
taskTotal: 5,
|
||||
pendingReview: 2,
|
||||
active: 1,
|
||||
rejected: 1,
|
||||
exempted: 1,
|
||||
completed: 1,
|
||||
rewardCents: 3000,
|
||||
pendingSettlementCents: 1200,
|
||||
confirmedSettlementCents: 800,
|
||||
paidSettlementCents: 600
|
||||
},
|
||||
byStatus: [{ status: 'SUBMITTED', total: 2, rewardCents: 1200 }],
|
||||
byStore: [{
|
||||
storeId: '11',
|
||||
storeName: 'Test Store',
|
||||
taskTotal: 5,
|
||||
pendingReview: 2,
|
||||
completed: 1,
|
||||
rejected: 1,
|
||||
exempted: 1,
|
||||
rewardCents: 3000
|
||||
}],
|
||||
settlements: [{ status: 'CONFIRMED', total: 1, rewardCents: 800 }],
|
||||
members: [{
|
||||
cleanerUserId: '31',
|
||||
cleanerName: 'Cleaner',
|
||||
taskCount: 3,
|
||||
completedTaskCount: 2,
|
||||
rejectedTaskCount: 1,
|
||||
pendingSettlementCents: 1200,
|
||||
settledRewardCents: 600
|
||||
}],
|
||||
trend: [{
|
||||
date: '2026-06-30',
|
||||
taskTotal: 5,
|
||||
pendingReview: 2,
|
||||
completed: 1,
|
||||
rejected: 1,
|
||||
exempted: 1,
|
||||
rewardCents: 3000,
|
||||
paidSettlementCents: 600
|
||||
}]
|
||||
};
|
||||
}
|
||||
},
|
||||
payoutService: {
|
||||
async preflightWechatTransfer(input) {
|
||||
calls.push(['preflightWechatTransfer', input]);
|
||||
return {
|
||||
ready: true,
|
||||
settlement: { id: input.settlementId, settlementNo: 'CLS-501', status: 'CONFIRMED' },
|
||||
account: { configured: true, merchantIdMasked: '19***0109' },
|
||||
credential: { configured: true, transferSceneId: '1000', reportInfoCount: 1 },
|
||||
cleaner: { openidConfigured: true },
|
||||
checks: [{ key: 'transfer_scene_id', status: 'PASS', message: 'ok' }]
|
||||
};
|
||||
},
|
||||
async executeWechatTransfer(input) {
|
||||
calls.push(['executeWechatTransfer', input]);
|
||||
return {
|
||||
settlement: settlementResponse(input.mode === 'MOCK' ? 'PAID' : 'CONFIRMED'),
|
||||
transferState: input.mode === 'MOCK' ? 'SUCCESS' : 'WAIT_USER_CONFIRM',
|
||||
idempotent: false
|
||||
};
|
||||
},
|
||||
async syncWechatTransfer(input) {
|
||||
calls.push(['syncWechatTransfer', input]);
|
||||
return {
|
||||
settlement: {
|
||||
...settlementResponse('PAID'),
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-transfer-sync-501'
|
||||
},
|
||||
transferState: 'SUCCESS',
|
||||
idempotent: false
|
||||
};
|
||||
},
|
||||
async processWechatTransferNotification(headers, rawBody, traceId) {
|
||||
calls.push(['processWechatTransferNotification', { headers, rawBody, traceId }]);
|
||||
return {
|
||||
settlement: {
|
||||
...settlementResponse('PAID'),
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-transfer-notify-501'
|
||||
},
|
||||
transferState: 'SUCCESS',
|
||||
idempotent: false
|
||||
};
|
||||
}
|
||||
},
|
||||
mediaStorage: {
|
||||
async storeImage(input) {
|
||||
calls.push(['storeImage', input]);
|
||||
return {
|
||||
storagePath: 'tenants/7/shared/cleaning.webp',
|
||||
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp',
|
||||
mimeType: 'image/webp',
|
||||
byteSize: input.body.length,
|
||||
width: 640,
|
||||
height: 480,
|
||||
checksumSha256: 'abc'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const hall = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(hall.statusCode, 200);
|
||||
assert.equal(hall.json().data.items[0].status, 'WAITING');
|
||||
assert.equal(calls.at(-1)[1].tenantId, '7');
|
||||
assert.equal(calls.at(-1)[1].userId, '31');
|
||||
|
||||
const mine = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/tasks/mine?status=CLAIMED',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(mine.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listMine');
|
||||
assert.equal(calls.at(-1)[1].status, 'CLAIMED');
|
||||
|
||||
const manageList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks?status=SUBMITTED&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(manageList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listManage');
|
||||
assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const managerStats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/statistics?from=2026-06-01&to=2026-07-01&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(managerStats.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'managerStatistics');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
assert.equal(managerStats.json().data.summary.pendingSettlementCents, 1200);
|
||||
assert.equal(managerStats.json().data.summary.exempted, 1);
|
||||
assert.equal(managerStats.json().data.byStore[0].exempted, 1);
|
||||
assert.equal(managerStats.json().data.members[0].completedTaskCount, 2);
|
||||
assert.equal(managerStats.json().data.members[0].rejectedTaskCount, 1);
|
||||
assert.equal(managerStats.json().data.trend[0].exempted, 1);
|
||||
assert.equal(managerStats.json().data.trend[0].paidSettlementCents, 600);
|
||||
|
||||
const claim = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/claim',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(claim.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'claim');
|
||||
assert.equal(calls.at(-1)[1].taskId, '101');
|
||||
|
||||
const start = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/start',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(start.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'start');
|
||||
|
||||
const rework = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/rework',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(rework.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'rework');
|
||||
|
||||
const photo = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/photos',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/octet-stream',
|
||||
'x-file-name': 'cleaning.jpg',
|
||||
'x-image-content-type': 'image/jpeg'
|
||||
},
|
||||
payload: Buffer.from('fake-image')
|
||||
});
|
||||
assert.equal(photo.statusCode, 201);
|
||||
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp');
|
||||
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto');
|
||||
assert.equal(calls.at(-1)[0], 'storeImage');
|
||||
|
||||
const submit = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/submit',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { photoUrls: ['https://api.txyundm.cn/uploads/cleaning/101.jpg'], note: 'ok' }
|
||||
});
|
||||
assert.equal(submit.statusCode, 200);
|
||||
assert.deepEqual(calls.at(-1)[1].photoUrls, ['https://api.txyundm.cn/uploads/cleaning/101.jpg']);
|
||||
|
||||
const assign = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/assign',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { cleanerUserId: '33', note: 'manual dispatch' }
|
||||
});
|
||||
assert.equal(assign.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'assign');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
|
||||
|
||||
const complete = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/complete',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'ok' }
|
||||
});
|
||||
assert.equal(complete.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'complete');
|
||||
|
||||
const reject = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/reject',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { reason: 'photo is unclear' }
|
||||
});
|
||||
assert.equal(reject.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reject');
|
||||
assert.equal(calls.at(-1)[1].reason, 'photo is unclear');
|
||||
|
||||
const exempt = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/exempt',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'no cleaning required' }
|
||||
});
|
||||
assert.equal(exempt.statusCode, 200);
|
||||
assert.equal(exempt.json().data.status, 'EXEMPT');
|
||||
assert.equal(calls.at(-1)[0], 'exempt');
|
||||
assert.equal(calls.at(-1)[1].note, 'no cleaning required');
|
||||
|
||||
const members = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks/101/members',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(members.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listMembers');
|
||||
assert.equal(members.json().data.length, 2);
|
||||
|
||||
const events = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks/101/events',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(events.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listEvents');
|
||||
assert.equal(calls.at(-1)[1].taskId, '101');
|
||||
assert.equal(events.json().data[0].action, 'SUBMIT');
|
||||
|
||||
const addedMember = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/members',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { cleanerUserId: '33', rewardCents: 100, note: 'support' }
|
||||
});
|
||||
assert.equal(addedMember.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'addMember');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
|
||||
assert.equal(calls.at(-1)[1].rewardCents, 100);
|
||||
|
||||
const removedMember = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/members/33/remove',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'done' }
|
||||
});
|
||||
assert.equal(removedMember.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'removeMember');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
|
||||
|
||||
const settlement = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlement-candidates?page=1&pageSize=10&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlement.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'settlementCandidates');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const settlementList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlementList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listSettlements');
|
||||
assert.equal(calls.at(-1)[1].status, 'DRAFT');
|
||||
assert.equal(calls.at(-1)[1].payoutState, 'FAIL');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const settlementDetail = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements/501',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlementDetail.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'getSettlementDetail');
|
||||
assert.equal(settlementDetail.json().data.items[0].taskNo, 'CLN-20260625-0001');
|
||||
|
||||
const generatedSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { cleanerUserId: '31', storeId: '11', note: 'weekly settlement' }
|
||||
});
|
||||
assert.equal(generatedSettlement.statusCode, 201);
|
||||
assert.equal(calls.at(-1)[0], 'generateSettlement');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const confirmedSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/confirm',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'confirmed' }
|
||||
});
|
||||
assert.equal(confirmedSettlement.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'confirmSettlement');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
|
||||
const failedPayout = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/payout-failure',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-failed-001',
|
||||
error: 'insufficient merchant balance',
|
||||
note: 'retry later'
|
||||
}
|
||||
});
|
||||
assert.equal(failedPayout.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'recordSettlementPayoutFailure');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
assert.equal(calls.at(-1)[1].error, 'insufficient merchant balance');
|
||||
|
||||
const paidSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/paid',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-paid-001',
|
||||
note: 'paid'
|
||||
}
|
||||
});
|
||||
assert.equal(paidSettlement.statusCode, 200);
|
||||
assert.equal(paidSettlement.json().data.status, 'PAID');
|
||||
assert.equal(paidSettlement.json().data.payoutReference, 'wx-paid-001');
|
||||
assert.equal(calls.at(-1)[0], 'markSettlementPaid');
|
||||
|
||||
const wechatTransfer = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/wechat-transfer',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { mode: 'API', note: 'wechat transfer' }
|
||||
});
|
||||
assert.equal(wechatTransfer.statusCode, 200);
|
||||
assert.equal(wechatTransfer.json().data.transferState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(calls.at(-1)[0], 'executeWechatTransfer');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
assert.equal(calls.at(-1)[1].mode, 'API');
|
||||
|
||||
const wechatTransferPreflight = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements/501/wechat-transfer/preflight',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(wechatTransferPreflight.statusCode, 200);
|
||||
assert.equal(wechatTransferPreflight.json().data.ready, true);
|
||||
assert.equal(wechatTransferPreflight.json().data.account.merchantIdMasked, '19***0109');
|
||||
assert.equal(calls.at(-1)[0], 'preflightWechatTransfer');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
|
||||
const syncedWechatTransfer = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/wechat-transfer/sync',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'poll wechat transfer' }
|
||||
});
|
||||
assert.equal(syncedWechatTransfer.statusCode, 200);
|
||||
assert.equal(syncedWechatTransfer.json().data.transferState, 'SUCCESS');
|
||||
assert.equal(syncedWechatTransfer.json().data.settlement.payoutReference, 'wx-transfer-sync-501');
|
||||
assert.equal(calls.at(-1)[0], 'syncWechatTransfer');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
|
||||
const wechatTransferNotify = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/wechat-transfer/notify',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'wechatpay-timestamp': '1782700001',
|
||||
'wechatpay-nonce': 'notify-nonce',
|
||||
'wechatpay-serial': 'PLATFORM-SERIAL',
|
||||
'wechatpay-signature': 'signature'
|
||||
},
|
||||
payload: '{"resource":"encrypted"}'
|
||||
});
|
||||
assert.equal(wechatTransferNotify.statusCode, 200);
|
||||
assert.equal(wechatTransferNotify.json().code, 'SUCCESS');
|
||||
assert.equal(calls.at(-1)[0], 'processWechatTransferNotification');
|
||||
assert.equal(calls.at(-1)[1].headers.serial, 'PLATFORM-SERIAL');
|
||||
assert.equal(calls.at(-1)[1].rawBody, '{"resource":"encrypted"}');
|
||||
|
||||
const reclaimed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/reclaim-timeouts',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { olderThanMinutes: 30, limit: 10 }
|
||||
});
|
||||
assert.equal(reclaimed.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
|
||||
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
|
||||
|
||||
const stats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/stats',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(stats.statusCode, 200);
|
||||
assert.equal(stats.json().data.pendingSettlementCents, 1200);
|
||||
assert.equal(stats.json().data.settledRewardCents, 600);
|
||||
assert.equal(stats.json().data.completionRate, 25);
|
||||
|
||||
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall' });
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
|
||||
const forbidden = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/tasks/hall',
|
||||
headers: { authorization: `Bearer ${forbiddenToken}` }
|
||||
});
|
||||
assert.equal(forbidden.statusCode, 403);
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M08-B cleaning routes authenticate and forward cleaner task workflows.');
|
||||
|
||||
function task(status) {
|
||||
return {
|
||||
id: '101',
|
||||
taskNo: 'CLN-20260625-0001',
|
||||
storeId: '11',
|
||||
storeName: 'Test Store',
|
||||
roomId: '21',
|
||||
roomName: 'A Room',
|
||||
roomNo: 'A01',
|
||||
orderId: '301',
|
||||
orderNo: 'O301',
|
||||
status,
|
||||
rewardCents: 600,
|
||||
photoUrls: []
|
||||
};
|
||||
}
|
||||
|
||||
function settlementResponse(status) {
|
||||
return {
|
||||
id: '501',
|
||||
settlementNo: 'CLS-20260626-0001',
|
||||
cleanerUserId: '31',
|
||||
cleanerName: 'Cleaner',
|
||||
storeId: '11',
|
||||
storeName: 'Test Store',
|
||||
status,
|
||||
taskCount: 2,
|
||||
totalRewardCents: 1200,
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
paidBy: status === 'PAID' ? '31' : null,
|
||||
confirmedAt: status === 'CONFIRMED' ? new Date() : null,
|
||||
paidAt: status === 'PAID' ? new Date() : null,
|
||||
payoutChannel: '',
|
||||
payoutReference: '',
|
||||
payoutError: '',
|
||||
note: ''
|
||||
};
|
||||
}
|
||||
|
||||
function settlementItemResponse() {
|
||||
return {
|
||||
id: '601',
|
||||
settlementId: '501',
|
||||
taskId: '101',
|
||||
taskNo: 'CLN-20260625-0001',
|
||||
orderNo: 'O301',
|
||||
storeName: 'Test Store',
|
||||
roomName: 'A Room',
|
||||
roomNo: 'A01',
|
||||
cleanerUserId: '31',
|
||||
cleanerName: 'Cleaner',
|
||||
rewardCents: 600,
|
||||
completedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
function member(memberRole, userId, rewardCents) {
|
||||
return {
|
||||
id: `${userId}01`,
|
||||
taskId: '101',
|
||||
userId,
|
||||
nickname: `Cleaner ${userId}`,
|
||||
memberRole,
|
||||
rewardCents,
|
||||
joinedAt: new Date(),
|
||||
removedAt: null,
|
||||
settledAt: null
|
||||
};
|
||||
}
|
||||
@@ -170,7 +170,7 @@ const baseInput = {
|
||||
orderId: '31',
|
||||
storeId: '11',
|
||||
orderAmountCents: 12000,
|
||||
orderStartAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
orderStartAt: new Date(Date.now() + 3_600_000),
|
||||
couponGrantId: '401',
|
||||
packageHoldingId: '501',
|
||||
packageMinutes: 60,
|
||||
|
||||
@@ -78,6 +78,24 @@ const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_pla
|
||||
const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql');
|
||||
const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql');
|
||||
const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql');
|
||||
const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql');
|
||||
const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql');
|
||||
const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql');
|
||||
const cleaningUpSql = read('database/migrations/2026062525_m08b_cleaner_tasks.up.sql');
|
||||
const cleaningDownSql = read('database/migrations/2026062525_m08b_cleaner_tasks.down.sql');
|
||||
const cleaningVerifySql = read('database/migrations/2026062525_m08b_cleaner_tasks.verify.sql');
|
||||
const cleaningSettlementUpSql = read('database/migrations/2026062626_m08b_cleaning_settlements.up.sql');
|
||||
const cleaningSettlementDownSql = read('database/migrations/2026062626_m08b_cleaning_settlements.down.sql');
|
||||
const cleaningSettlementVerifySql = read('database/migrations/2026062626_m08b_cleaning_settlements.verify.sql');
|
||||
const cleaningCollaborationUpSql = read('database/migrations/2026062627_m08b_cleaning_collaboration.up.sql');
|
||||
const cleaningCollaborationDownSql = read('database/migrations/2026062627_m08b_cleaning_collaboration.down.sql');
|
||||
const cleaningCollaborationVerifySql = read('database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql');
|
||||
const cleaningPayoutUpSql = read('database/migrations/2026062728_m08b_cleaning_payouts.up.sql');
|
||||
const cleaningPayoutDownSql = read('database/migrations/2026062728_m08b_cleaning_payouts.down.sql');
|
||||
const cleaningPayoutVerifySql = read('database/migrations/2026062728_m08b_cleaning_payouts.verify.sql');
|
||||
const cleaningTransferStateUpSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql');
|
||||
const cleaningTransferStateDownSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql');
|
||||
const cleaningTransferStateVerifySql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -364,4 +382,72 @@ assert.match(benefitUpSql, /remaining_amount_cents INT UNSIGNED/);
|
||||
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/);
|
||||
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/);
|
||||
|
||||
console.log('PASS: M01-B through M07-C migration contracts are present.');
|
||||
assert.match(rechargeWechatUpSql, /ALTER TABLE qipai_recharge_orders/);
|
||||
assert.match(rechargeWechatUpSql, /provider_prepay_id VARCHAR\(128\) NULL/);
|
||||
assert.match(rechargeWechatUpSql, /provider_payment_id VARCHAR\(128\) NULL/);
|
||||
assert.match(rechargeWechatUpSql, /provider_callback_id VARCHAR\(128\) NULL/);
|
||||
assert.match(rechargeWechatUpSql, /raw_notify JSON NULL/);
|
||||
assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_payment/);
|
||||
assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_callback/);
|
||||
assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
|
||||
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
|
||||
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
|
||||
|
||||
for (const table of ['qipai_cleaning_tasks', 'qipai_cleaning_task_events']) {
|
||||
assert.match(cleaningUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(cleaningDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(cleaningVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
for (const status of [
|
||||
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
|
||||
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
|
||||
]) {
|
||||
assert.match(cleaningUpSql, new RegExp(status));
|
||||
}
|
||||
assert.match(cleaningUpSql, /cleaner_user_id BIGINT UNSIGNED NULL/);
|
||||
assert.match(cleaningUpSql, /photo_urls_json JSON NOT NULL/);
|
||||
assert.match(cleaningUpSql, /uq_qipai_cleaning_task_order/);
|
||||
assert.match(cleaningUpSql, /cleaning\.task\.write/);
|
||||
assert.match(cleaningUpSql, /cleaning\.statistics\.read/);
|
||||
|
||||
for (const table of ['qipai_cleaning_settlements', 'qipai_cleaning_settlement_items']) {
|
||||
assert.match(cleaningSettlementUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(cleaningSettlementDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(cleaningSettlementVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.match(cleaningSettlementUpSql, /settlement_no VARCHAR\(64\) NOT NULL/);
|
||||
assert.match(cleaningSettlementUpSql, /total_reward_cents INT UNSIGNED NOT NULL/);
|
||||
assert.match(cleaningSettlementUpSql, /uq_qipai_cleaning_settlement_item_task/);
|
||||
assert.match(cleaningSettlementUpSql, /cleaning\.settlement\.read/);
|
||||
assert.match(cleaningSettlementUpSql, /cleaning\.settlement\.write/);
|
||||
|
||||
assert.match(cleaningCollaborationUpSql, /CREATE TABLE IF NOT EXISTS qipai_cleaning_task_members/);
|
||||
assert.match(cleaningCollaborationUpSql, /member_role VARCHAR\(16\) NOT NULL DEFAULT 'ASSIST'/);
|
||||
assert.match(cleaningCollaborationUpSql, /settled_at DATETIME\(3\) NULL/);
|
||||
assert.match(cleaningCollaborationUpSql, /DROP INDEX uq_qipai_cleaning_settlement_item_task/);
|
||||
assert.match(cleaningCollaborationUpSql, /cleaner_user_id BIGINT UNSIGNED NOT NULL/);
|
||||
assert.match(cleaningCollaborationUpSql, /uq_qipai_cleaning_settlement_item_task_user/);
|
||||
assert.match(cleaningCollaborationDownSql, /DROP TABLE IF EXISTS qipai_cleaning_task_members/);
|
||||
assert.match(cleaningCollaborationDownSql, /ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task/);
|
||||
assert.match(cleaningCollaborationVerifySql, /'qipai_cleaning_task_members'/);
|
||||
assert.match(cleaningCollaborationVerifySql, /'cleaner_user_id'/);
|
||||
|
||||
assert.match(cleaningPayoutUpSql, /ADD COLUMN paid_by BIGINT UNSIGNED NULL/);
|
||||
assert.match(cleaningPayoutUpSql, /ADD COLUMN payout_channel VARCHAR\(32\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningPayoutUpSql, /ADD COLUMN payout_reference VARCHAR\(128\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningPayoutUpSql, /ADD COLUMN payout_error VARCHAR\(512\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningPayoutUpSql, /fk_qipai_cleaning_settlements_paid_by/);
|
||||
assert.match(cleaningPayoutUpSql, /idx_qipai_cleaning_settlement_payout/);
|
||||
assert.match(cleaningPayoutDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_settlements_paid_by/);
|
||||
assert.match(cleaningPayoutDownSql, /DROP COLUMN payout_error/);
|
||||
assert.match(cleaningPayoutVerifySql, /'payout_reference'/);
|
||||
assert.match(cleaningPayoutVerifySql, /'2026062728'/);
|
||||
|
||||
assert.match(cleaningTransferStateUpSql, /ADD COLUMN payout_state VARCHAR\(32\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningTransferStateUpSql, /ADD COLUMN payout_package_info VARCHAR\(1024\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningTransferStateUpSql, /idx_qipai_cleaning_settlement_transfer_state/);
|
||||
assert.match(cleaningTransferStateDownSql, /DROP COLUMN payout_package_info/);
|
||||
assert.match(cleaningTransferStateVerifySql, /'payout_state'/);
|
||||
assert.match(cleaningTransferStateVerifySql, /'2026062729'/);
|
||||
|
||||
console.log('PASS: M01-B through M08-B migration contracts are present.');
|
||||
|
||||
@@ -34,7 +34,12 @@ assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
|
||||
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
|
||||
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
|
||||
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
|
||||
assert.match(plan.file, /2026062423_m07c_benefits\.up\.sql$/);
|
||||
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/);
|
||||
assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql/);
|
||||
assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
|
||||
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
|
||||
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
|
||||
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -76,6 +76,15 @@ assert.equal(created.statusCode, 201);
|
||||
assert.equal(createInput.platformAppId, '9');
|
||||
assert.equal('amountCents' in createInput, false);
|
||||
|
||||
const balanceCreated = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/payments',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { orderId: '31', provider: 'BALANCE', clientRequestId: 'request-balance-0001' }
|
||||
});
|
||||
assert.equal(balanceCreated.statusCode, 201);
|
||||
assert.equal(createInput.provider, 'BALANCE');
|
||||
|
||||
const completed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/payments/51/test-complete',
|
||||
|
||||
@@ -65,6 +65,13 @@ const reserved = await app.inject({
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
pricingMode: 'HOURLY',
|
||||
benefits: {
|
||||
couponGrantId: '401',
|
||||
packageHoldingId: '501',
|
||||
packageMinutes: 60,
|
||||
packageCreditCents: 2000,
|
||||
clientRequestId: 'benefit-route-0001'
|
||||
},
|
||||
totalCents: 1,
|
||||
discountCents: 999999
|
||||
}
|
||||
@@ -74,6 +81,13 @@ assert.equal(reserveInput.tenantId, '7');
|
||||
assert.equal(reserveInput.userId, '21');
|
||||
assert.equal('totalCents' in reserveInput, false);
|
||||
assert.equal('discountCents' in reserveInput, false);
|
||||
assert.deepEqual(reserveInput.benefits, {
|
||||
couponGrantId: '401',
|
||||
packageHoldingId: '501',
|
||||
packageMinutes: 60,
|
||||
packageCreditCents: 2000,
|
||||
clientRequestId: 'benefit-route-0001'
|
||||
});
|
||||
|
||||
const unauthenticated = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -10,6 +10,8 @@ const token = signAccessToken({
|
||||
|
||||
let createdInput;
|
||||
let listInput;
|
||||
let prepayInput;
|
||||
let notifyInput;
|
||||
const app = await buildApp({
|
||||
recharge: {
|
||||
jwtSecret: secret,
|
||||
@@ -61,6 +63,23 @@ const app = await buildApp({
|
||||
giftAmountCents: 2000,
|
||||
idempotent: false
|
||||
};
|
||||
},
|
||||
async createWechatPrepay(input) {
|
||||
prepayInput = input;
|
||||
return {
|
||||
rechargeOrderId: input.rechargeOrderId,
|
||||
rechargeNo: 'RCHTEST',
|
||||
amountCents: 10000,
|
||||
timeStamp: '1782360000',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=wx-recharge-prepay',
|
||||
signType: 'RSA',
|
||||
paySign: 'signed'
|
||||
};
|
||||
},
|
||||
async processWechatNotification(headers, rawBody, traceId) {
|
||||
notifyInput = { headers, rawBody, traceId };
|
||||
return { rechargeOrderId: '901', status: 'CREDITED', idempotent: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +110,33 @@ assert.equal(createdInput.userId, '21');
|
||||
assert.equal(createdInput.planId, '31');
|
||||
assert.equal(createdInput.traceId, 'm08a-recharge-order');
|
||||
|
||||
const prepay = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/recharge/orders/901/wechat-prepay',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(prepay.statusCode, 200);
|
||||
assert.equal(prepayInput.tenantId, '7');
|
||||
assert.equal(prepayInput.platformAppId, '9');
|
||||
assert.equal(prepayInput.userId, '21');
|
||||
assert.equal(prepay.json().data.package, 'prepay_id=wx-recharge-prepay');
|
||||
|
||||
const notify = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/recharge/wechat/notify',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'wechatpay-timestamp': '1782360001',
|
||||
'wechatpay-nonce': 'notify-nonce',
|
||||
'wechatpay-serial': 'PLATFORM-SERIAL',
|
||||
'wechatpay-signature': 'signature'
|
||||
},
|
||||
payload: '{"resource":"encrypted"}'
|
||||
});
|
||||
assert.equal(notify.statusCode, 200);
|
||||
assert.equal(notifyInput.headers.serial, 'PLATFORM-SERIAL');
|
||||
assert.equal(notifyInput.rawBody, '{"resource":"encrypted"}');
|
||||
|
||||
const invalid = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/recharge/orders',
|
||||
|
||||
@@ -21,6 +21,7 @@ function createHarness(overrides = {}) {
|
||||
...overrides.plan
|
||||
},
|
||||
orders: [],
|
||||
callbacks: [],
|
||||
nextOrderId: 900,
|
||||
purchaseCount: overrides.purchaseCount ?? 0,
|
||||
walletCredits: []
|
||||
@@ -71,12 +72,60 @@ function createHarness(overrides = {}) {
|
||||
state.orders.push(order);
|
||||
return [{ insertId: order.id, affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('user_id = ?')) {
|
||||
const order = state.orders.find((item) =>
|
||||
item.tenantId === String(params[0])
|
||||
&& item.id === String(params[1])
|
||||
&& item.userId === String(params[2])
|
||||
);
|
||||
return [order ? [toOrderRow(order)] : [], []];
|
||||
}
|
||||
if (sql.includes('SELECT openid FROM qipai_user_identities')) {
|
||||
return [[{ openid: 'openid-recharge-test' }], []];
|
||||
}
|
||||
if (sql.includes('provider_prepay_id')) {
|
||||
const order = state.orders.find((item) =>
|
||||
item.tenantId === String(params[1])
|
||||
&& item.id === String(params[2])
|
||||
&& item.userId === String(params[3])
|
||||
);
|
||||
order.providerPrepayId = params[0];
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('recharge_no = ?')) {
|
||||
const order = state.orders.find((item) => item.rechargeNo === String(params[0]));
|
||||
return [order ? [{ ...toOrderRow(order), tenantId: order.tenantId }] : [], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) {
|
||||
const order = state.orders.find((item) =>
|
||||
item.tenantId === String(params[0]) && item.id === String(params[1])
|
||||
);
|
||||
return [order ? [toOrderRow(order)] : [], []];
|
||||
}
|
||||
if (sql.includes('INSERT IGNORE INTO qipai_payment_callbacks')) {
|
||||
const duplicate = state.callbacks.some((item) =>
|
||||
item.tenantId === String(params[0]) && item.callbackId === String(params[1])
|
||||
);
|
||||
if (!duplicate) {
|
||||
state.callbacks.push({
|
||||
tenantId: String(params[0]),
|
||||
callbackId: String(params[1]),
|
||||
status: 'RECEIVED',
|
||||
payload: params[2]
|
||||
});
|
||||
}
|
||||
return [{ affectedRows: duplicate ? 0 : 1 }, []];
|
||||
}
|
||||
if (sql.includes('provider_payment_id')) {
|
||||
const order = state.orders.find((item) =>
|
||||
item.tenantId === String(params[3]) && item.id === String(params[4])
|
||||
);
|
||||
order.status = 'PAID';
|
||||
order.providerPaymentId = String(params[0]);
|
||||
order.providerCallbackId = String(params[1]);
|
||||
order.rawNotify = params[2];
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes("SET status = 'PAID'")) {
|
||||
const order = state.orders.find((item) =>
|
||||
item.tenantId === String(params[1]) && item.id === String(params[2])
|
||||
@@ -92,11 +141,23 @@ function createHarness(overrides = {}) {
|
||||
order.status = 'CREDITED';
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('UPDATE qipai_payment_callbacks')) {
|
||||
const callback = state.callbacks.find((item) =>
|
||||
item.tenantId === String(params[0]) && item.callbackId === String(params[1])
|
||||
) ?? state.callbacks.find((item) =>
|
||||
item.tenantId === String(params[1]) && item.callbackId === String(params[2])
|
||||
);
|
||||
if (callback) callback.status = sql.includes('REJECTED') ? 'REJECTED' : 'PROCESSED';
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
};
|
||||
|
||||
const pool = {
|
||||
async execute(sql, params) {
|
||||
return connection.execute(sql, params);
|
||||
},
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
@@ -113,7 +174,58 @@ function createHarness(overrides = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
return { service: new RechargeService(pool, wallet), state };
|
||||
const wechat = {
|
||||
paymentRepository: {
|
||||
async resolveConfig(_connection, tenantId, platformAppId, storeId, provider) {
|
||||
state.resolvedConfig = { tenantId, platformAppId, storeId, provider };
|
||||
return {
|
||||
credentialRef: 'wechat-test',
|
||||
settings: {
|
||||
rechargeDescription: '测试充值',
|
||||
rechargeNotifyUrl: 'https://api.txyundm.cn/app-api/recharge/wechat/notify'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
client: {
|
||||
async createJsapiPrepay(_credential, input) {
|
||||
state.prepayInput = input;
|
||||
return {
|
||||
prepayId: 'wx-recharge-prepay',
|
||||
paymentParams: {
|
||||
timeStamp: '1782360000',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=wx-recharge-prepay',
|
||||
signType: 'RSA',
|
||||
paySign: 'signed'
|
||||
}
|
||||
};
|
||||
},
|
||||
verifyAndDecrypt(_credential, headers, rawBody) {
|
||||
state.verifiedNotification = { headers, rawBody };
|
||||
return {
|
||||
out_trade_no: state.orders[0].rechargeNo,
|
||||
transaction_id: 'wx-recharge-transaction',
|
||||
trade_state: 'SUCCESS',
|
||||
amount: { total: state.orders[0].payAmountCents },
|
||||
payer: { openid: 'redacted' }
|
||||
};
|
||||
}
|
||||
},
|
||||
credentials: new Map([[
|
||||
'wechat-test',
|
||||
{
|
||||
appId: 'wx-test-app',
|
||||
merchantId: '1900000109',
|
||||
serialNo: 'MERCHANT-SERIAL',
|
||||
privateKeyPem: 'test-private-key',
|
||||
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||
platformCertificates: { 'PLATFORM-SERIAL': 'test-cert' }
|
||||
}
|
||||
]])
|
||||
};
|
||||
|
||||
return { service: new RechargeService(pool, wallet, wechat), state };
|
||||
}
|
||||
|
||||
function toOrderRow(order) {
|
||||
@@ -217,4 +329,41 @@ const baseOrder = {
|
||||
assert.equal(state.walletCredits.length, 1);
|
||||
}
|
||||
|
||||
{
|
||||
const { service, state } = createHarness();
|
||||
const created = await service.createRechargeOrder(baseOrder);
|
||||
const prepay = await service.createWechatPrepay({
|
||||
tenantId: '7',
|
||||
platformAppId: '9',
|
||||
userId: '21',
|
||||
rechargeOrderId: created.rechargeOrderId
|
||||
});
|
||||
assert.equal(prepay.package, 'prepay_id=wx-recharge-prepay');
|
||||
assert.equal(state.resolvedConfig.storeId, '11');
|
||||
assert.equal(state.prepayInput.outTradeNo, created.rechargeNo);
|
||||
assert.equal(state.prepayInput.amountCents, 10000);
|
||||
assert.equal(state.orders[0].providerPrepayId, 'wx-recharge-prepay');
|
||||
|
||||
const notified = await service.processWechatNotification({
|
||||
timestamp: '1782360001',
|
||||
nonce: 'notify-nonce',
|
||||
serial: 'PLATFORM-SERIAL',
|
||||
signature: 'signed'
|
||||
}, '{"resource":"encrypted"}', 'recharge-notify');
|
||||
assert.equal(notified.status, 'CREDITED');
|
||||
assert.equal(state.orders[0].providerPaymentId, 'wx-recharge-transaction');
|
||||
assert.equal(state.orders[0].providerCallbackId, 'PLATFORM-SERIAL:wx-recharge-transaction:SUCCESS');
|
||||
assert.equal(state.walletCredits.length, 1);
|
||||
assert.equal(JSON.parse(state.orders[0].rawNotify).payer, undefined);
|
||||
|
||||
const duplicate = await service.processWechatNotification({
|
||||
timestamp: '1782360001',
|
||||
nonce: 'notify-nonce',
|
||||
serial: 'PLATFORM-SERIAL',
|
||||
signature: 'signed'
|
||||
}, '{"resource":"encrypted"}', 'recharge-notify');
|
||||
assert.equal(duplicate.idempotent, true);
|
||||
assert.equal(state.walletCredits.length, 1);
|
||||
}
|
||||
|
||||
console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.');
|
||||
|
||||
@@ -49,6 +49,8 @@ const token = signAccessToken({
|
||||
rv: 1
|
||||
}, secret, 900);
|
||||
let createdInput;
|
||||
let listedInput;
|
||||
let updatedInput;
|
||||
const app = await buildApp({
|
||||
userManagement: {
|
||||
jwtSecret: secret,
|
||||
@@ -72,12 +74,18 @@ const app = await buildApp({
|
||||
}
|
||||
},
|
||||
repository: {
|
||||
async listUsers() { return { items: [], total: 0 }; },
|
||||
async listUsers(input) {
|
||||
listedInput = input;
|
||||
return { items: [], total: 0 };
|
||||
},
|
||||
async createStaff(_actor, input) {
|
||||
createdInput = input;
|
||||
return { userId: '22' };
|
||||
},
|
||||
async updateUser() { return { userId: '22' }; },
|
||||
async updateUser(_actor, _userId, input) {
|
||||
updatedInput = input;
|
||||
return { userId: '22' };
|
||||
},
|
||||
async resetSessions() { return { userId: '22', revokedSessions: 2 }; }
|
||||
}
|
||||
}
|
||||
@@ -97,6 +105,32 @@ assert.equal(created.statusCode, 201);
|
||||
assert.equal(created.json().data.userId, '22');
|
||||
assert.deepEqual(createdInput.storeIds, ['11']);
|
||||
|
||||
const cleaners = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/users?role=CLEANER&status=ACTIVE&search=%E4%BF%9D%E6%B4%81',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(cleaners.statusCode, 200);
|
||||
assert.equal(listedInput.role, 'CLEANER');
|
||||
assert.equal(listedInput.status, 'ACTIVE');
|
||||
assert.equal(listedInput.search, '保洁');
|
||||
|
||||
const updated = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/admin-api/users/22',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
nickname: '保洁一号',
|
||||
phone: '13800138009',
|
||||
roles: ['CLEANER'],
|
||||
storeIds: ['11'],
|
||||
note: '夜班'
|
||||
}
|
||||
});
|
||||
assert.equal(updated.statusCode, 200);
|
||||
assert.equal(updatedInput.phone, '13800138009');
|
||||
assert.deepEqual(updatedInput.roles, ['CLEANER']);
|
||||
|
||||
const unauthenticated = await app.inject({ method: 'GET', url: '/admin-api/users' });
|
||||
assert.equal(unauthenticated.statusCode, 401);
|
||||
await app.close();
|
||||
|
||||
@@ -29,7 +29,20 @@ const responses = [
|
||||
{ prepay_id: 'wx-prepay-test' },
|
||||
{ trade_state: 'SUCCESS', transaction_id: 'wx-transaction-test' },
|
||||
{ refund_id: 'wx-refund-test', status: 'PROCESSING' },
|
||||
{ download_url: 'https://api.mch.weixin.qq.com/v3/billdownload/file?token=sanitized' }
|
||||
{ download_url: 'https://api.mch.weixin.qq.com/v3/billdownload/file?token=sanitized' },
|
||||
{
|
||||
out_bill_no: 'CLP501',
|
||||
transfer_bill_no: 'wx-transfer-501',
|
||||
state: 'WAIT_USER_CONFIRM',
|
||||
package_info: 'package-info'
|
||||
},
|
||||
{
|
||||
mch_id: '1900000109',
|
||||
out_bill_no: 'CLP501',
|
||||
transfer_bill_no: 'wx-transfer-501',
|
||||
state: 'SUCCESS',
|
||||
transfer_amount: 800
|
||||
}
|
||||
];
|
||||
const transport = {
|
||||
async request(input) {
|
||||
@@ -73,6 +86,27 @@ assert.match((await client.downloadTradeBill(
|
||||
credential, '2026-06-21', 'ALL'
|
||||
)).download_url, /^https:/);
|
||||
|
||||
const transfer = await client.createMerchantTransfer(credential, {
|
||||
outBillNo: 'CLP501',
|
||||
openid: 'openid-sanitized',
|
||||
amountCents: 800,
|
||||
remark: '保洁结算501',
|
||||
sceneId: '1000',
|
||||
reportInfos: [{ infoType: '岗位类型', infoContent: '保洁员' }]
|
||||
});
|
||||
assert.equal(transfer.state, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(transfer.packageInfo, 'package-info');
|
||||
assert.match(requests[4].url, /\/v3\/fund-app\/mch-transfer\/transfer-bills$/);
|
||||
assert.match(requests[4].body, /"out_bill_no":"CLP501"/);
|
||||
assert.match(requests[4].body, /"transfer_amount":800/);
|
||||
assert.equal(requests[4].headers.Authorization.includes(merchantKeys.privateKey), false);
|
||||
|
||||
const queriedTransfer = await client.queryMerchantTransferByOutBillNo(credential, 'CLP501');
|
||||
assert.equal(queriedTransfer.state, 'SUCCESS');
|
||||
assert.equal(queriedTransfer.amountCents, 800);
|
||||
assert.match(requests[5].url, /\/v3\/fund-app\/mch-transfer\/transfer-bills\/out-bill-no\/CLP501$/);
|
||||
assert.equal(requests[5].headers.Authorization.includes(merchantKeys.privateKey), false);
|
||||
|
||||
const notification = encryptedNotification({
|
||||
out_trade_no: 'PAY-M05B-001',
|
||||
transaction_id: 'wx-transaction-test',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE qipai_recharge_orders
|
||||
DROP KEY uq_qipai_recharge_provider_callback,
|
||||
DROP KEY uq_qipai_recharge_provider_payment,
|
||||
DROP COLUMN raw_notify,
|
||||
DROP COLUMN provider_callback_id,
|
||||
DROP COLUMN provider_payment_id,
|
||||
DROP COLUMN provider_prepay_id,
|
||||
DROP COLUMN payment_provider;
|
||||
|
||||
DELETE FROM qipai_schema_migrations
|
||||
WHERE version = '2026062524';
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE qipai_recharge_orders
|
||||
ADD COLUMN payment_provider VARCHAR(32) NOT NULL DEFAULT 'WECHAT' AFTER payment_id,
|
||||
ADD COLUMN provider_prepay_id VARCHAR(128) NULL AFTER payment_provider,
|
||||
ADD COLUMN provider_payment_id VARCHAR(128) NULL AFTER provider_prepay_id,
|
||||
ADD COLUMN provider_callback_id VARCHAR(128) NULL AFTER provider_payment_id,
|
||||
ADD COLUMN raw_notify JSON NULL AFTER provider_callback_id,
|
||||
ADD UNIQUE KEY uq_qipai_recharge_provider_payment
|
||||
(tenant_id, payment_provider, provider_payment_id),
|
||||
ADD UNIQUE KEY uq_qipai_recharge_provider_callback
|
||||
(tenant_id, payment_provider, provider_callback_id);
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062524', 'm08a_recharge_wechat');
|
||||
@@ -0,0 +1,20 @@
|
||||
SELECT COUNT(*) AS expected_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_recharge_orders'
|
||||
AND column_name IN (
|
||||
'payment_provider',
|
||||
'provider_prepay_id',
|
||||
'provider_payment_id',
|
||||
'provider_callback_id',
|
||||
'raw_notify'
|
||||
);
|
||||
|
||||
SELECT COUNT(*) AS expected_indexes
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_recharge_orders'
|
||||
AND index_name IN (
|
||||
'uq_qipai_recharge_provider_payment',
|
||||
'uq_qipai_recharge_provider_callback'
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
DELETE rp FROM qipai_role_permissions rp
|
||||
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
|
||||
WHERE p.code IN ('cleaning.task.write', 'cleaning.statistics.read');
|
||||
|
||||
DELETE FROM qipai_permissions
|
||||
WHERE code IN ('cleaning.task.write', 'cleaning.statistics.read');
|
||||
|
||||
DROP TABLE IF EXISTS qipai_cleaning_task_events;
|
||||
DROP TABLE IF EXISTS qipai_cleaning_tasks;
|
||||
|
||||
DELETE FROM qipai_schema_migrations
|
||||
WHERE version = '2026062525';
|
||||
@@ -0,0 +1,76 @@
|
||||
CREATE TABLE IF NOT EXISTS qipai_cleaning_tasks (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
room_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NULL,
|
||||
task_no VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'WAITING',
|
||||
cleaner_user_id BIGINT UNSIGNED NULL,
|
||||
priority TINYINT UNSIGNED NOT NULL DEFAULT 5,
|
||||
reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
requirement VARCHAR(512) NOT NULL DEFAULT '',
|
||||
photo_urls_json JSON NOT NULL,
|
||||
reject_reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
claimed_at DATETIME(3) NULL,
|
||||
started_at DATETIME(3) NULL,
|
||||
submitted_at DATETIME(3) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
settled_at DATETIME(3) NULL,
|
||||
cancelled_at DATETIME(3) 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),
|
||||
deleted_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_cleaning_tasks_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_cleaning_tasks_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_cleaning_tasks_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||
CONSTRAINT fk_qipai_cleaning_tasks_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
|
||||
CONSTRAINT fk_qipai_cleaning_tasks_cleaner FOREIGN KEY (cleaner_user_id) REFERENCES qipai_users(id),
|
||||
CONSTRAINT chk_qipai_cleaning_task_status CHECK (
|
||||
status IN (
|
||||
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
|
||||
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
|
||||
)
|
||||
),
|
||||
UNIQUE KEY uq_qipai_cleaning_task_no (tenant_id, task_no),
|
||||
UNIQUE KEY uq_qipai_cleaning_task_order (tenant_id, order_id),
|
||||
KEY idx_qipai_cleaning_hall (tenant_id, store_id, status, priority, created_at),
|
||||
KEY idx_qipai_cleaning_cleaner (tenant_id, cleaner_user_id, status, updated_at),
|
||||
KEY idx_qipai_cleaning_room (tenant_id, room_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_cleaning_task_events (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
from_status VARCHAR(32) NULL,
|
||||
to_status VARCHAR(32) NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
actor_id BIGINT UNSIGNED NOT NULL,
|
||||
trace_id VARCHAR(128) NOT NULL,
|
||||
note VARCHAR(512) NOT NULL DEFAULT '',
|
||||
metadata JSON NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_cleaning_events_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_cleaning_events_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id),
|
||||
UNIQUE KEY uq_qipai_cleaning_event_trace (tenant_id, task_id, trace_id),
|
||||
KEY idx_qipai_cleaning_events_task (tenant_id, task_id, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
|
||||
('cleaning.task.write', '处理保洁任务', 'cleaning'),
|
||||
('cleaning.statistics.read', '查看保洁统计', 'cleaning');
|
||||
|
||||
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
|
||||
SELECT r.tenant_id, r.id, p.id
|
||||
FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p
|
||||
ON (r.code = 'CLEANER' AND p.code IN (
|
||||
'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'
|
||||
))
|
||||
OR (r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'))
|
||||
WHERE r.status = 'ACTIVE' AND r.deleted_at IS NULL;
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062525', 'm08b_cleaner_tasks');
|
||||
@@ -0,0 +1,30 @@
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN ('qipai_cleaning_tasks', 'qipai_cleaning_task_events');
|
||||
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_tasks'
|
||||
AND column_name IN (
|
||||
'task_no', 'status', 'cleaner_user_id', 'reward_cents',
|
||||
'photo_urls_json', 'reject_reason', 'started_at', 'submitted_at'
|
||||
);
|
||||
|
||||
SELECT index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_tasks'
|
||||
AND index_name IN (
|
||||
'uq_qipai_cleaning_task_no', 'uq_qipai_cleaning_task_order',
|
||||
'idx_qipai_cleaning_hall', 'idx_qipai_cleaning_cleaner'
|
||||
);
|
||||
|
||||
SELECT code
|
||||
FROM qipai_permissions
|
||||
WHERE code IN ('cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read');
|
||||
|
||||
SELECT version
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version = '2026062525';
|
||||
@@ -0,0 +1,12 @@
|
||||
DELETE rp FROM qipai_role_permissions rp
|
||||
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
|
||||
WHERE p.code IN ('cleaning.settlement.read', 'cleaning.settlement.write');
|
||||
|
||||
DELETE FROM qipai_permissions
|
||||
WHERE code IN ('cleaning.settlement.read', 'cleaning.settlement.write');
|
||||
|
||||
DROP TABLE IF EXISTS qipai_cleaning_settlement_items;
|
||||
DROP TABLE IF EXISTS qipai_cleaning_settlements;
|
||||
|
||||
DELETE FROM qipai_schema_migrations
|
||||
WHERE version = '2026062626';
|
||||
@@ -0,0 +1,61 @@
|
||||
CREATE TABLE IF NOT EXISTS qipai_cleaning_settlements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
settlement_no VARCHAR(64) NOT NULL,
|
||||
cleaner_user_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
|
||||
task_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
period_start DATETIME(3) NULL,
|
||||
period_end DATETIME(3) NULL,
|
||||
generated_by BIGINT UNSIGNED NOT NULL,
|
||||
confirmed_by BIGINT UNSIGNED NULL,
|
||||
confirmed_at DATETIME(3) NULL,
|
||||
paid_at DATETIME(3) NULL,
|
||||
note VARCHAR(512) NOT NULL DEFAULT '',
|
||||
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),
|
||||
deleted_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_cleaning_settlements_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_cleaning_settlements_cleaner FOREIGN KEY (cleaner_user_id) REFERENCES qipai_users(id),
|
||||
CONSTRAINT fk_qipai_cleaning_settlements_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_cleaning_settlements_generated_by FOREIGN KEY (generated_by) REFERENCES qipai_users(id),
|
||||
CONSTRAINT fk_qipai_cleaning_settlements_confirmed_by FOREIGN KEY (confirmed_by) REFERENCES qipai_users(id),
|
||||
CONSTRAINT chk_qipai_cleaning_settlement_status CHECK (
|
||||
status IN ('DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED')
|
||||
),
|
||||
UNIQUE KEY uq_qipai_cleaning_settlement_no (tenant_id, settlement_no),
|
||||
KEY idx_qipai_cleaning_settlement_cleaner (tenant_id, cleaner_user_id, status, created_at),
|
||||
KEY idx_qipai_cleaning_settlement_store (tenant_id, store_id, status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_cleaning_settlement_items (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
settlement_id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_cleaning_settlement_items_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_cleaning_settlement_items_settlement FOREIGN KEY (settlement_id) REFERENCES qipai_cleaning_settlements(id),
|
||||
CONSTRAINT fk_qipai_cleaning_settlement_items_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id),
|
||||
UNIQUE KEY uq_qipai_cleaning_settlement_item_task (tenant_id, task_id),
|
||||
KEY idx_qipai_cleaning_settlement_items_parent (tenant_id, settlement_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
|
||||
('cleaning.settlement.read', '查看保洁结算', 'cleaning'),
|
||||
('cleaning.settlement.write', '生成保洁结算', 'cleaning');
|
||||
|
||||
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
|
||||
SELECT r.tenant_id, r.id, p.id
|
||||
FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p
|
||||
ON (r.code = 'CLEANER' AND p.code = 'cleaning.settlement.read')
|
||||
OR (r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('cleaning.settlement.read', 'cleaning.settlement.write'))
|
||||
WHERE r.status = 'ACTIVE' AND r.deleted_at IS NULL;
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062626', 'm08b_cleaning_settlements');
|
||||
@@ -0,0 +1,33 @@
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN ('qipai_cleaning_settlements', 'qipai_cleaning_settlement_items');
|
||||
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_settlements'
|
||||
AND column_name IN (
|
||||
'settlement_no', 'cleaner_user_id', 'status', 'task_count',
|
||||
'total_reward_cents', 'period_start', 'period_end', 'confirmed_at', 'paid_at'
|
||||
);
|
||||
|
||||
SELECT index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN ('qipai_cleaning_settlements', 'qipai_cleaning_settlement_items')
|
||||
AND index_name IN (
|
||||
'uq_qipai_cleaning_settlement_no',
|
||||
'idx_qipai_cleaning_settlement_cleaner',
|
||||
'idx_qipai_cleaning_settlement_store',
|
||||
'uq_qipai_cleaning_settlement_item_task',
|
||||
'idx_qipai_cleaning_settlement_items_parent'
|
||||
);
|
||||
|
||||
SELECT code
|
||||
FROM qipai_permissions
|
||||
WHERE code IN ('cleaning.settlement.read', 'cleaning.settlement.write');
|
||||
|
||||
SELECT version
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version = '2026062626';
|
||||
@@ -0,0 +1,11 @@
|
||||
DELETE FROM qipai_schema_migrations
|
||||
WHERE version = '2026062627';
|
||||
|
||||
ALTER TABLE qipai_cleaning_settlement_items
|
||||
DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_cleaner,
|
||||
DROP INDEX uq_qipai_cleaning_settlement_item_task_user,
|
||||
DROP INDEX idx_qipai_cleaning_settlement_items_cleaner,
|
||||
DROP COLUMN cleaner_user_id,
|
||||
ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task (tenant_id, task_id);
|
||||
|
||||
DROP TABLE IF EXISTS qipai_cleaning_task_members;
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE IF NOT EXISTS qipai_cleaning_task_members (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
member_role VARCHAR(16) NOT NULL DEFAULT 'ASSIST',
|
||||
reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
removed_at DATETIME(3) NULL,
|
||||
settled_at DATETIME(3) 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_task_members_tenant FOREIGN KEY (tenant_id)
|
||||
REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_cleaning_task_members_task FOREIGN KEY (task_id)
|
||||
REFERENCES qipai_cleaning_tasks(id),
|
||||
CONSTRAINT fk_qipai_cleaning_task_members_user FOREIGN KEY (user_id)
|
||||
REFERENCES qipai_users(id),
|
||||
UNIQUE KEY uq_qipai_cleaning_task_member (tenant_id, task_id, user_id),
|
||||
KEY idx_qipai_cleaning_task_member_user (tenant_id, user_id, removed_at, settled_at),
|
||||
KEY idx_qipai_cleaning_task_member_task (tenant_id, task_id, removed_at),
|
||||
CONSTRAINT chk_qipai_cleaning_task_member_role CHECK (member_role IN ('LEAD', 'ASSIST'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE qipai_cleaning_settlement_items
|
||||
DROP INDEX uq_qipai_cleaning_settlement_item_task,
|
||||
ADD COLUMN cleaner_user_id BIGINT UNSIGNED NOT NULL AFTER task_id,
|
||||
ADD CONSTRAINT fk_qipai_cleaning_settlement_items_cleaner FOREIGN KEY (cleaner_user_id)
|
||||
REFERENCES qipai_users(id),
|
||||
ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task_user (tenant_id, task_id, cleaner_user_id),
|
||||
ADD KEY idx_qipai_cleaning_settlement_items_cleaner (tenant_id, cleaner_user_id);
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062627', 'm08b_cleaning_collaboration');
|
||||
@@ -0,0 +1,31 @@
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_task_members';
|
||||
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND (
|
||||
(table_name = 'qipai_cleaning_task_members'
|
||||
AND column_name IN ('task_id', 'user_id', 'member_role', 'reward_cents', 'removed_at', 'settled_at'))
|
||||
OR (table_name = 'qipai_cleaning_settlement_items'
|
||||
AND column_name = 'cleaner_user_id')
|
||||
);
|
||||
|
||||
SELECT table_name, index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN ('qipai_cleaning_task_members', 'qipai_cleaning_settlement_items')
|
||||
AND index_name IN (
|
||||
'uq_qipai_cleaning_task_member',
|
||||
'idx_qipai_cleaning_task_member_user',
|
||||
'idx_qipai_cleaning_task_member_task',
|
||||
'uq_qipai_cleaning_settlement_item_task_user',
|
||||
'idx_qipai_cleaning_settlement_items_cleaner'
|
||||
)
|
||||
GROUP BY table_name, index_name;
|
||||
|
||||
SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version = '2026062627';
|
||||
@@ -0,0 +1,10 @@
|
||||
DELETE FROM qipai_schema_migrations
|
||||
WHERE version = '2026062728';
|
||||
|
||||
ALTER TABLE qipai_cleaning_settlements
|
||||
DROP FOREIGN KEY fk_qipai_cleaning_settlements_paid_by,
|
||||
DROP INDEX idx_qipai_cleaning_settlement_payout,
|
||||
DROP COLUMN payout_error,
|
||||
DROP COLUMN payout_reference,
|
||||
DROP COLUMN payout_channel,
|
||||
DROP COLUMN paid_by;
|
||||
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE qipai_cleaning_settlements
|
||||
ADD COLUMN paid_by BIGINT UNSIGNED NULL AFTER confirmed_at,
|
||||
ADD COLUMN payout_channel VARCHAR(32) NOT NULL DEFAULT '' AFTER paid_at,
|
||||
ADD COLUMN payout_reference VARCHAR(128) NOT NULL DEFAULT '' AFTER payout_channel,
|
||||
ADD COLUMN payout_error VARCHAR(512) NOT NULL DEFAULT '' AFTER payout_reference,
|
||||
ADD CONSTRAINT fk_qipai_cleaning_settlements_paid_by FOREIGN KEY (paid_by) REFERENCES qipai_users(id),
|
||||
ADD KEY idx_qipai_cleaning_settlement_payout (tenant_id, status, paid_at);
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062728', 'm08b_cleaning_payouts');
|
||||
@@ -0,0 +1,22 @@
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_settlements'
|
||||
AND column_name IN ('paid_by', 'payout_channel', 'payout_reference', 'payout_error');
|
||||
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_settlements'
|
||||
AND constraint_name = 'fk_qipai_cleaning_settlements_paid_by';
|
||||
|
||||
SELECT index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_settlements'
|
||||
AND index_name = 'idx_qipai_cleaning_settlement_payout'
|
||||
GROUP BY index_name;
|
||||
|
||||
SELECT version
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version = '2026062728';
|
||||
@@ -0,0 +1,7 @@
|
||||
DELETE FROM qipai_schema_migrations
|
||||
WHERE version = '2026062729';
|
||||
|
||||
ALTER TABLE qipai_cleaning_settlements
|
||||
DROP INDEX idx_qipai_cleaning_settlement_transfer_state,
|
||||
DROP COLUMN payout_package_info,
|
||||
DROP COLUMN payout_state;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE qipai_cleaning_settlements
|
||||
ADD COLUMN payout_state VARCHAR(32) NOT NULL DEFAULT '' AFTER payout_reference,
|
||||
ADD COLUMN payout_package_info VARCHAR(1024) NOT NULL DEFAULT '' AFTER payout_state,
|
||||
ADD KEY idx_qipai_cleaning_settlement_transfer_state (tenant_id, payout_state, updated_at);
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062729', 'm08b_cleaning_transfer_state');
|
||||
@@ -0,0 +1,16 @@
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_settlements'
|
||||
AND column_name IN ('payout_state', 'payout_package_info');
|
||||
|
||||
SELECT index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_cleaning_settlements'
|
||||
AND index_name = 'idx_qipai_cleaning_settlement_transfer_state'
|
||||
GROUP BY index_name;
|
||||
|
||||
SELECT version
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version = '2026062729';
|
||||
@@ -0,0 +1,35 @@
|
||||
# M08-A 充值微信支付 API
|
||||
|
||||
- `POST /app-api/recharge/orders/:rechargeOrderId/wechat-prepay`
|
||||
- 鉴权:顾客登录态,需 `profile.read`。
|
||||
- 行为:校验充值单属于当前用户,读取微信支付配置和当前用户 openid,创建 JSAPI 预支付。
|
||||
- 返回:`rechargeOrderId`、`rechargeNo`、`amountCents`、`timeStamp`、`nonceStr`、`package`、`signType`、`paySign`。
|
||||
|
||||
- `POST /app-api/recharge/wechat/notify`
|
||||
- 鉴权:微信支付平台证书签名和 AES-GCM 资源解密。
|
||||
- 行为:按 `out_trade_no` 定位充值单,校验金额,写入 `qipai_payment_callbacks` 幂等记录,成功后给钱包现金/赠送余额入账。
|
||||
- 隐私:通知 payload 入库前移除 `payer` 等敏感对象。
|
||||
|
||||
## M08-A 权益与余额下单抵扣
|
||||
|
||||
- `POST /app-api/orders/reserve`
|
||||
- 新增可选 `benefits` 对象:`couponGrantId`、`packageHoldingId`、`packageMinutes`、`packageCreditCents`、`clientRequestId`。
|
||||
- 行为:只接受权益 ID 和请求幂等号,不接受客户端自报总价;后端按报价、权益规则和剩余量重新计算 `discountCents`、`packageCreditCents` 与订单 `totalCents`。
|
||||
- 返回:保留原订单预占结构,新增 `benefitReservation`,包含冻结状态、抵扣金额、套餐抵扣金额、分钟和使用记录。
|
||||
|
||||
- `POST /app-api/payments`
|
||||
- `provider` 继续支持 `WECHAT`、`TEST`,新增顾客端 `BALANCE` 实际扣款闭环。
|
||||
- `BALANCE` 行为:按当前订单应付金额创建支付单,在同一事务内从门店钱包按赠送余额优先、现金余额补足扣款;成功后支付单状态为 `SUCCEEDED`,订单满足金额时推进为 `PAID` 并确认冻结权益。
|
||||
- `WECHAT`/`TEST` 成功路径:支付通知或测试回调成功后统一确认冻结优惠券和套餐;重复通知保持幂等。
|
||||
|
||||
- `POST /app-api/orders/:orderId/cancel`
|
||||
- 行为补充:订单取消释放房间预占和访问权限时,同时释放仍处于 `FROZEN` 的优惠券和套餐持有记录。
|
||||
|
||||
## M08-A 权益可视化选择器补充
|
||||
|
||||
- `GET /app-api/profile/benefits`
|
||||
- 小程序房间页现在复用该接口作为下单权益选择器数据源。
|
||||
- 前端按当前 `storeId`、`roomId`、权益状态和剩余量过滤可选项。
|
||||
|
||||
- `POST /app-api/orders/reserve`
|
||||
- 行为补充:`TIME` 类型优惠券不再只冻结记录,会按当前服务端锁定单价和券分钟数折算成订单折扣;最终折扣仍受服务端计价规则限制,不允许抵扣押金外的非法金额。
|
||||
@@ -0,0 +1,218 @@
|
||||
# 2026-06-25 M08-B 保洁端 API
|
||||
|
||||
## App API
|
||||
|
||||
- `GET /app-api/cleaning/tasks/hall`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 查询:`page`、`pageSize`、`status`;默认查询 `WAITING`。
|
||||
- 说明:按保洁员门店范围返回任务大厅。
|
||||
|
||||
- `GET /app-api/cleaning/tasks/mine`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 查询:`page`、`pageSize`、`status`。
|
||||
- 说明:返回当前保洁员已领取任务。
|
||||
|
||||
- `POST /app-api/cleaning/tasks/:taskId/claim`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 说明:仅 `WAITING` 且未被领取的任务可接单。
|
||||
|
||||
- `POST /app-api/cleaning/tasks/:taskId/start`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 说明:当前保洁员已领取的 `CLAIMED` 任务可开始。
|
||||
|
||||
- `POST /app-api/cleaning/tasks/:taskId/photos`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Content-Type:`application/octet-stream`。
|
||||
- Headers:`x-file-name`、`x-image-content-type`。
|
||||
- 说明:当前保洁员本人 `STARTED` 或 `REJECTED` 任务可上传验收照片;服务端压缩存储后返回 `publicUrl`。
|
||||
|
||||
- `POST /app-api/cleaning/tasks/:taskId/submit`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`photoUrls: string[]`,`note?: string`。
|
||||
- 说明:当前保洁员本人 `STARTED` 或 `REJECTED` 任务可提交验收;`photoUrls` 至少 1 张,最多 9 张。
|
||||
|
||||
- `POST /app-api/cleaning/tasks/:taskId/rework`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 说明:当前保洁员本人 `REJECTED` 任务可重新进入 `STARTED` 补做状态。
|
||||
|
||||
- `GET /app-api/cleaning/stats`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 说明:返回当前保洁员任务状态计数和待结算金额。
|
||||
|
||||
## Admin API
|
||||
|
||||
- `GET /admin-api/cleaning/tasks`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 查询:`page`、`pageSize`、`status`。
|
||||
- 说明:按管理员门店范围查询保洁任务。
|
||||
|
||||
- `POST /admin-api/cleaning/tasks/:taskId/assign`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`cleanerUserId: string`,`note?: string`。
|
||||
- 说明:仅可将任务指派给具备 `CLEANER` 角色且拥有该门店范围的有效用户。
|
||||
|
||||
- `POST /admin-api/cleaning/tasks/:taskId/complete`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`note?: string`。
|
||||
- 说明:将 `SUBMITTED` 任务验收为 `COMPLETED`,并进入待结算候选。
|
||||
|
||||
- `POST /admin-api/cleaning/tasks/:taskId/reject`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`reason: string`。
|
||||
- 说明:将 `SUBMITTED` 任务驳回为 `REJECTED`,保洁端可查看原因并补做。
|
||||
|
||||
- `GET /admin-api/cleaning/settlement-candidates`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 查询:`page`、`pageSize`。
|
||||
- 说明:返回 `COMPLETED` 且 `settled_at IS NULL` 的待结算保洁任务。
|
||||
|
||||
- `POST /admin-api/cleaning/reclaim-timeouts`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`olderThanMinutes?: number`,`limit?: number`。
|
||||
- 说明:将超时停留在 `CLAIMED` 或 `STARTED` 的任务回收到 `WAITING`,并清空保洁员领取状态。
|
||||
|
||||
- `GET /admin-api/cleaning/settlements`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 查询:`page`、`pageSize`、`status`。
|
||||
- 说明:查询保洁结算单,状态支持 `DRAFT`、`CONFIRMED`、`PAID`、`CANCELLED`。
|
||||
|
||||
- `POST /admin-api/cleaning/settlements`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`cleanerUserId: string`,`storeId?: string`,`note?: string`。
|
||||
- 说明:将指定保洁员已完成未结算任务生成 `DRAFT` 结算单,任务同步转为 `SETTLED` 并写入结算明细。
|
||||
|
||||
- `POST /admin-api/cleaning/settlements/:settlementId/confirm`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`note?: string`。
|
||||
- 说明:将 `DRAFT` 结算单确认成 `CONFIRMED`,进入发放待处理状态。
|
||||
|
||||
- `POST /admin-api/cleaning/settlements/:settlementId/paid`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`payoutChannel: string`,`payoutReference: string`,`note?: string`。
|
||||
- 说明:将 `CONFIRMED` 结算单标记为 `PAID`,记录发放人、发放时间、渠道和外部流水号,清空旧失败原因。
|
||||
|
||||
- `POST /admin-api/cleaning/settlements/:settlementId/payout-failure`
|
||||
- 登录:必需。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`error: string`,`payoutChannel?: string`,`payoutReference?: string`,`note?: string`。
|
||||
- 说明:记录 `CONFIRMED` 结算单发放失败原因,保留可重试状态。
|
||||
## 2026-06-26 多保洁员协作增量
|
||||
|
||||
- `GET /admin-api/cleaning/tasks/:taskId/members`
|
||||
- 登录:必须。
|
||||
- 权限:`cleaning.task.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 说明:返回任务有效保洁成员,包含 `LEAD` 负责人和 `ASSIST` 协作者、成员分账金额与结算时间。
|
||||
- `POST /admin-api/cleaning/tasks/:taskId/members`
|
||||
- 登录:必须。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`cleanerUserId: string`,`rewardCents: number`,`note?: string`。
|
||||
- 说明:添加或更新协作者分账金额;协作者必须为有效 `CLEANER` 且拥有任务门店范围;协作者金额总和不能超过任务奖励。
|
||||
- `POST /admin-api/cleaning/tasks/:taskId/members/:cleanerUserId/remove`
|
||||
- 登录:必须。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- Body:`note?: string`。
|
||||
- 说明:移除未结算协作者并把剩余奖励回算到负责人;负责人不可通过该接口移除。
|
||||
- `POST /admin-api/cleaning/settlements`
|
||||
- 追加说明:多人协作任务按 `qipai_cleaning_task_members.reward_cents` 分别为每个保洁员生成结算明细;所有有效成员均完成结算后任务才整体转为 `SETTLED`。
|
||||
## 2026-06-27 微信转账适配增量
|
||||
|
||||
- `POST /admin-api/cleaning/settlements/:settlementId/wechat-transfer`
|
||||
- 登录:必须。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`;服务层继续校验 `cleaning.settlement.write`。
|
||||
- Body:`mode?: "API" | "MOCK"`,`note?: string`。
|
||||
- 说明:对 `CONFIRMED` 保洁结算单发起微信商家转账。`API` 模式会校验门店/全局微信收款账户、商户凭据、保洁员微信 openid 和转账场景配置;`MOCK` 仅非生产环境可用。
|
||||
- 返回:成功转账直接返回 `PAID` 结算单;微信返回 `FAIL` 时记录失败原因;微信返回待确认或处理中状态时保留 `CONFIRMED`,并返回 `payoutState`、`payoutReference` 和 `payoutPackageInfo`。
|
||||
|
||||
## 2026-06-29 微信转账回调与轮询增量
|
||||
|
||||
- `POST /admin-api/cleaning/settlements/:settlementId/wechat-transfer/sync`
|
||||
- 登录:必须。
|
||||
- 权限:`cleaning.task.write`、`tenant.manage` 或 `PLATFORM_ADMIN`;服务层继续校验 `cleaning.settlement.write`。
|
||||
- Body:`note?: string`。
|
||||
- 说明:按结算单商户转账单号主动查询微信商家转账状态;`SUCCESS` 自动标记 `PAID`,`FAIL` 记录失败原因,其它中间态保留 `CONFIRMED` 并更新 `payout_state`。
|
||||
|
||||
- `POST /app-api/cleaning/wechat-transfer/notify`
|
||||
- 登录:不需要,依赖微信支付通知签名验证。
|
||||
- Headers:`wechatpay-timestamp`、`wechatpay-nonce`、`wechatpay-serial`、`wechatpay-signature`。
|
||||
- Body:微信支付 v3 加密通知原文。
|
||||
- 说明:服务端用原始 body 验签解密,校验商户号后按 `out_bill_no` 或 `transfer_bill_no` 定位保洁结算单,并写回成功、失败或中间态。
|
||||
|
||||
## 2026-06-30 后台协作与结算详情增量
|
||||
|
||||
- `GET /admin-api/cleaning/settlements/:settlementId`
|
||||
- 登录:必须。
|
||||
- 权限:`cleaning.settlement.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 说明:按租户和门店数据范围读取结算单详情,返回结算单主信息与固化结算明细。
|
||||
- 返回:`settlement` 与 `items`;明细包含任务号、订单号、门店、房间、保洁员、成员分账金额和完成时间。
|
||||
|
||||
## 2026-06-30 统计明细增量
|
||||
|
||||
- `GET /admin-api/cleaning/statistics`
|
||||
- 登录:必须。
|
||||
- 权限:`cleaning.task.read` 与 `cleaning.settlement.read`,或 `tenant.manage` / `PLATFORM_ADMIN`。
|
||||
- Query:`from?: string`,`to?: string`,`storeId?: string`。
|
||||
- 说明:按登录管理员租户与门店范围统计保洁运营状态,支持日期和门店筛选。
|
||||
- 返回:`summary`、`byStatus`、`byStore`、`settlements`、`members`、`trend`,覆盖任务状态、门店明细、结算口径、成员分账待结算金额和每日趋势。
|
||||
|
||||
## 2026-06-30 保洁员资料管理增量
|
||||
|
||||
- `GET /admin-api/users`
|
||||
- 追加 Query:`role?: "CLEANER" | "STAFF" | "STORE_ADMIN" | "TENANT_ADMIN"`。
|
||||
- 说明:用于后台保洁员资料管理筛选 `CLEANER` 角色;权限、租户和门店范围继续沿用员工管理接口。
|
||||
- `POST /admin-api/staff`
|
||||
- 复用说明:后台创建保洁员时传入 `roles: ["CLEANER"]` 和门店范围。
|
||||
- `PATCH /admin-api/users/:id`
|
||||
- 复用说明:后台编辑保洁员姓名、备注、门店范围和启停状态。
|
||||
- `POST /admin-api/users/:id/reset-sessions`
|
||||
- 复用说明:后台重置保洁员会话,使旧登录态立即失效。
|
||||
|
||||
## 2026-06-30 统计趋势增量
|
||||
|
||||
- `GET /admin-api/cleaning/statistics`
|
||||
- 追加返回:`trend[]`。
|
||||
- 说明:在既有日期、门店和管理员门店范围过滤基础上,按任务创建日期返回每日任务数、待验收数、完成数、驳回数、奖励金额和已发放金额,供后台运营趋势展示使用。
|
||||
|
||||
## 2026-06-30 保洁员资料细节增量
|
||||
|
||||
- `GET /admin-api/users`
|
||||
- 追加返回:`avatarUrl`、`wechatMiniappBound`。
|
||||
- 说明:后台保洁员资料列表可展示头像资料来源和微信小程序 openid 绑定状态,用于判断微信转账前置资料是否齐全。
|
||||
- `PATCH /admin-api/users/:id`
|
||||
- 追加说明:后台保洁员编辑弹窗已接入手机号更新,继续沿用原有员工管理权限、租户隔离、门店范围和角色分配校验。
|
||||
|
||||
## 2026-06-30 统计按保洁员筛选增量
|
||||
|
||||
- `GET /admin-api/cleaning/statistics`
|
||||
- 追加 Query:`cleanerUserId?: string`。
|
||||
- 说明:管理统计支持按保洁员筛选;任务口径覆盖负责人和有效协作者,结算口径按结算单保洁员过滤,每日趋势中的已发放金额同步应用该筛选。
|
||||
|
||||
## 2026-06-30 待结算候选预览增量
|
||||
|
||||
- `GET /admin-api/cleaning/settlement-candidates`
|
||||
- 追加 Query:`storeId?: string`,`cleanerUserId?: string`。
|
||||
- 说明:后台可按门店和保洁员预览待生成结算单的 `COMPLETED` 且未结算任务;保洁员口径覆盖负责人和未移除、未结算的协作者。
|
||||
|
||||
## 2026-06-30 任务事件流水增量
|
||||
|
||||
- `GET /admin-api/cleaning/tasks/:taskId/events`
|
||||
- 登录:必须。
|
||||
- 权限:`cleaning.task.read`、`tenant.manage` 或 `PLATFORM_ADMIN`。
|
||||
- 说明:按租户和管理员门店范围返回保洁任务最近 50 条事件流水,包含动作、from/to 状态、操作人、traceId、备注和创建时间。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> V5.0 首次核验日期:2026-06-16
|
||||
> audited_commit: `4ae9296`
|
||||
> next_engineering_target: 继续 M08-A 充值微信支付调起和权益下单抵扣集成
|
||||
> next_engineering_target: 继续 M08-B 更完整保洁运营管理界面
|
||||
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
|
||||
|
||||
## 总体结论
|
||||
@@ -12,8 +12,8 @@
|
||||
| 总纲版本 | V5.4 已成为当前权威总纲,旧版已保留为历史备份 | 根目录存在 `V5.4.md` 及旧版总纲 | 可继续按 V5.4 的执行游标开发 |
|
||||
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main` 为 `0 0` | 本地与远端同步 |
|
||||
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**`、`database/migrations/2026061805_m02c_rbac.*.sql`、`backend/tests/**` | M01、M02-A/B/C DONE;MySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D |
|
||||
| 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE |
|
||||
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单前半链路 | `miniapp/pages/index`、`miniapp/pages/store/detail`、`miniapp/pages/room/detail`、`miniapp/pages/orders/*`、`miniapp/utils/api.js`、`scripts/check-miniapp-m08-a.mjs` | M08-A PARTIAL;已支持选店、门店/房间浏览、报价、预占、测试支付、受控 Wi-Fi、订单列表/详情、状态历史、取消和订单权限开门入口,仍不能标记 DONE |
|
||||
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 工程,并接入 M08-B 保洁任务、免清洁标记、批量免清洁、免清洁统计展示、协作者管理、结算详情、统计明细、每日趋势、保洁员资料管理、手机号维护、微信绑定状态、微信转账预检界面、现场联调检查项搜索、筛选结果导出和筛选清除 | `admin/src/App.vue`、`admin/src/components/CleaningTasksPanel.vue`、`admin/src/components/CleaningSettlementsPanel.vue`、`admin/src/components/CleaningStatisticsPanel.vue`、`admin/src/components/CleanersPanel.vue`、`admin/src/components/CleaningFieldHandoffPanel.vue`、`scripts/check-admin-m08-b.mjs` | M08-B 后台起点已具备,可维护任务成员、单条或批量标记任务免清洁、查看免清洁汇总/门店/趋势统计、查看结算详情、统计明细、每日趋势、管理保洁员资料完整度、检索现场联调检查项、导出或清除当前筛选联调结果和查看结算微信转账前置条件;M09 完整后台仍未开始,不能标记 DONE |
|
||||
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路和保洁任务端照片上传/补做主体链路 | `miniapp/pages/index`、`miniapp/pages/store/detail`、`miniapp/pages/room/detail`、`miniapp/pages/orders/*`、`miniapp/pages/recharge/*`、`miniapp/pages/profile/*`、`miniapp/pages/cleaner/*`、`miniapp/utils/api.js`、`scripts/check-miniapp-m08-a.mjs` | M08 PARTIAL;M08-A 顾客端工程闭环按外部阻塞旁路,M08-B 已支持保洁任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回/免清洁、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放记录、微信转账预检、主动同步、通知回调、后台协作者管理、结算详情、统计明细、每日趋势、保洁员资料管理、手机号维护和微信绑定状态,仍不能标记 DONE |
|
||||
| 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql`、`database/seeds/2026061601_m01b_minimal_seed.sql`、`database/fixtures/2026061801_m01b_legacy_schema.sql`、`backend/src/db/migration-runner.ts`、`backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONE;WSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 |
|
||||
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh`、`scripts/setup/`、`deploy/` | PARTIAL,生产未执行 |
|
||||
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .`、`scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
|
||||
@@ -35,6 +35,6 @@
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 继续 M08-A:在已完成选店、门店/房间浏览、报价、预占、测试支付、订单微信预支付调起、受控 Wi-Fi、订单列表/详情、取消、订单权限开门、续费、换房、分享、个人中心、充值入口和权益明细基础上,补充值微信支付调起和权益下单抵扣集成。
|
||||
1. 继续 M08-B:在已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回/免清洁/批量免清洁、免清洁统计展示、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、微信转账预检、主动同步、通知回调、后台协作者管理、结算详情、统计明细、每日趋势、保洁员资料管理、手机号维护、微信绑定状态、联调检查项搜索和联调筛选结果导出和筛选清除基础上,补更完整保洁运营管理界面。
|
||||
2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。
|
||||
3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# M08-A 充值微信支付字段
|
||||
|
||||
- 迁移:`database/migrations/2026062524_m08a_recharge_wechat.*.sql`
|
||||
- 目的:让充值单独立记录微信预支付、交易号、回调幂等键和脱敏通知,不复用订单支付的 `order_id` 语义。
|
||||
- 正向变更:`qipai_recharge_orders` 新增 `payment_provider`、`provider_prepay_id`、`provider_payment_id`、`provider_callback_id`、`raw_notify`,并增加交易号与回调唯一键。
|
||||
- 回滚:删除新增唯一键和字段,删除迁移版本 `2026062524`。
|
||||
- 验证:检查 5 个新增字段和 2 个唯一索引存在。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 2026-06-25 M08-B 保洁端数据库变更
|
||||
|
||||
## 新增迁移
|
||||
|
||||
- `database/migrations/2026062525_m08b_cleaner_tasks.up.sql`
|
||||
- `database/migrations/2026062525_m08b_cleaner_tasks.down.sql`
|
||||
- `database/migrations/2026062525_m08b_cleaner_tasks.verify.sql`
|
||||
- `database/migrations/2026062626_m08b_cleaning_settlements.up.sql`
|
||||
- `database/migrations/2026062626_m08b_cleaning_settlements.down.sql`
|
||||
- `database/migrations/2026062626_m08b_cleaning_settlements.verify.sql`
|
||||
|
||||
## 新增表
|
||||
|
||||
- `qipai_cleaning_tasks`
|
||||
- 保洁任务主表,包含租户、门店、房间、订单、任务号、状态、保洁员、奖励金额、要求、照片 URL、驳回原因和关键时间戳。
|
||||
- 状态约束:`WAITING`、`CLAIMED`、`STARTED`、`SUBMITTED`、`COMPLETED`、`REJECTED`、`EXEMPT`、`SETTLED`、`CANCELLED`。
|
||||
- 唯一约束:租户内任务号唯一;订单关联唯一。
|
||||
- 索引:任务大厅、保洁员任务、房间任务。
|
||||
|
||||
- `qipai_cleaning_task_events`
|
||||
- 保洁任务状态事件表,记录 from/to 状态、动作、操作人、trace、备注和 metadata。
|
||||
- 租户、任务、trace 唯一,便于幂等追踪。
|
||||
|
||||
- `qipai_cleaning_settlements`
|
||||
- 保洁结算单主表,包含结算号、保洁员、门店、状态、任务数、总奖励金额、结算周期、生成人、确认人、确认/支付时间和备注。
|
||||
- 状态约束:`DRAFT`、`CONFIRMED`、`PAID`、`CANCELLED`。
|
||||
- 唯一约束:租户内结算号唯一。
|
||||
|
||||
- `qipai_cleaning_settlement_items`
|
||||
- 保洁结算单明细表,关联结算单与任务并固化任务奖励金额。
|
||||
- 唯一约束:同一租户内同一任务只能进入一个结算单。
|
||||
|
||||
## 权限
|
||||
|
||||
- 新增 `cleaning.task.write`。
|
||||
- 新增 `cleaning.statistics.read`。
|
||||
- 将 `cleaning.task.read`、`cleaning.task.write`、`cleaning.statistics.read` 授予现有 `CLEANER`、`STORE_ADMIN`、`TENANT_ADMIN` 和 `PLATFORM_ADMIN` 角色。
|
||||
- 新增 `cleaning.settlement.read` 和 `cleaning.settlement.write`。
|
||||
- 将 `cleaning.settlement.read` 授予 `CLEANER`、`STORE_ADMIN`、`TENANT_ADMIN` 和 `PLATFORM_ADMIN`;将 `cleaning.settlement.write` 授予 `STORE_ADMIN`、`TENANT_ADMIN` 和 `PLATFORM_ADMIN`。
|
||||
## 2026-06-26 多保洁员协作增量
|
||||
|
||||
- 新增迁移:
|
||||
- `database/migrations/2026062627_m08b_cleaning_collaboration.up.sql`
|
||||
- `database/migrations/2026062627_m08b_cleaning_collaboration.down.sql`
|
||||
- `database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql`
|
||||
- 新增 `qipai_cleaning_task_members`:
|
||||
- 记录任务保洁成员,`member_role` 支持 `LEAD` 和 `ASSIST`。
|
||||
- `reward_cents` 记录成员分账金额,`removed_at` 记录移除,`settled_at` 记录成员维度结算完成。
|
||||
- 同一租户、任务、用户唯一,支持负责人和协作者查询索引。
|
||||
- 调整 `qipai_cleaning_settlement_items`:
|
||||
- 新增 `cleaner_user_id`,结算明细从“任务唯一”升级为“任务 + 保洁员唯一”。
|
||||
- 原 `uq_qipai_cleaning_settlement_item_task` 替换为 `uq_qipai_cleaning_settlement_item_task_user`,支持同一协作任务按不同成员分别结算。
|
||||
|
||||
## 2026-06-27 发放闭环增量
|
||||
|
||||
- 新增迁移:
|
||||
- `database/migrations/2026062728_m08b_cleaning_payouts.up.sql`
|
||||
- `database/migrations/2026062728_m08b_cleaning_payouts.down.sql`
|
||||
- `database/migrations/2026062728_m08b_cleaning_payouts.verify.sql`
|
||||
- 调整 `qipai_cleaning_settlements`:
|
||||
- 新增 `paid_by`,关联发放确认操作人。
|
||||
- 新增 `payout_channel`,记录发放渠道,例如微信零钱、企业付款或线下人工发放。
|
||||
- 新增 `payout_reference`,记录外部付款流水或人工凭证号。
|
||||
- 新增 `payout_error`,记录最近一次发放失败原因,成功标记为 `PAID` 时清空。
|
||||
- 新增 `idx_qipai_cleaning_settlement_payout`,支持按租户、状态和发放时间查询。
|
||||
## 2026-06-27 微信转账适配增量
|
||||
|
||||
- 新增迁移:
|
||||
- `database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql`
|
||||
- `database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql`
|
||||
- `database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql`
|
||||
- 调整 `qipai_cleaning_settlements`:
|
||||
- 新增 `payout_state`,记录微信转账结果或中间态,例如 `SUCCESS`、`FAIL`、`WAIT_USER_CONFIRM`、`PROCESSING`。
|
||||
- 新增 `payout_package_info`,保存微信待用户确认等场景返回的 package 信息。
|
||||
- 新增 `idx_qipai_cleaning_settlement_transfer_state`,支持按租户、转账状态和更新时间筛选待跟进结算单。
|
||||
- 既有 `payout_channel`、`payout_reference`、`payout_error` 继续保存发放渠道、外部流水和最近失败原因;`PAID` 时同步将 `payout_state` 标记为 `SUCCESS`。
|
||||
@@ -157,3 +157,80 @@ M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单
|
||||
|
||||
- 工程提交:`e24920c`
|
||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||
|
||||
## 2026-06-25 续接:顾客端充值微信支付调起
|
||||
|
||||
- 数据库新增 `2026062524_m08a_recharge_wechat` 迁移,为 `qipai_recharge_orders` 增加微信预支付 ID、微信交易号、回调幂等 ID 和脱敏通知 JSON,避免把充值单伪造成订单支付。
|
||||
- `RechargeService` 新增 `createWechatPrepay` 和 `processWechatNotification`,充值预支付复用微信支付配置、顾客 openid 和 JSAPI 参数生成;微信通知验签解密后按充值单号入账,重复通知不重复加余额。
|
||||
- `POST /app-api/recharge/orders/:rechargeOrderId/wechat-prepay` 暴露给小程序,校验当前登录顾客只能为本人充值单调起支付。
|
||||
- `POST /app-api/recharge/wechat/notify` 新增充值微信支付通知入口,通知 payload 入库前移除 `payer`。
|
||||
- 小程序充值页新增“微信支付”按钮,创建充值单后可调起微信支付;到账结果以后端通知和个人中心余额为准。
|
||||
- 迁移 runner 白名单纳入新迁移,并补充 API/DB changelog。
|
||||
|
||||
验收:
|
||||
|
||||
- `npm run build`(`backend/`):PASS。
|
||||
- `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- `node tests/recharge-service.test.mjs`:PASS。
|
||||
- `node tests/recharge-route.test.mjs`:PASS。
|
||||
- `node tests/migration-contract.test.mjs`:PASS。
|
||||
- `node tests/migration-runner.test.mjs`:PASS。
|
||||
- `node tests/wechat-pay.test.mjs`:PASS。
|
||||
- `npm test`(`backend/`):PASS。
|
||||
|
||||
状态:
|
||||
|
||||
- M08-A 仍为 `PARTIAL`;余额/套餐/优惠券下单抵扣深度集成、真机合法域名、真实微信支付和实物开门验证待继续。
|
||||
|
||||
提交:
|
||||
|
||||
- 工程提交:`53f5d67`
|
||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||
|
||||
## 2026-06-25 续接:顾客端权益与余额下单抵扣
|
||||
|
||||
- `PricingRepository.reserve` 接入优惠券/套餐权益冻结:顾客预占订单时可传入优惠券发放 ID、套餐持有 ID、套餐分钟和套餐金额抵扣,后端先按服务端价格创建预占,再在同一数据库事务中冻结权益、重算价格快照和订单应付金额。
|
||||
- `MarketingBenefitService` 与 `WalletLedgerService` 新增事务内复用方法,供订单预占、支付成功和取消释放共享同一事务边界。
|
||||
- `PaymentRepository` 新增余额支付闭环:顾客创建 `BALANCE` 支付单时跳过外部支付配置,按门店钱包先赠送后现金扣款,支付单立即成功并推进订单;测试支付与微信支付成功回调统一确认冻结权益。
|
||||
- `OrderStateRepository` 在订单取消、关闭、退款完成等释放预占/撤销权限的状态路径中释放仍处于冻结状态的优惠券和套餐。
|
||||
- 微信支付通知成功路径复用统一支付成功结算,避免测试支付和微信支付两条路径对权益确认逻辑分叉。
|
||||
- 小程序房间页新增优惠券 ID、套餐 ID、套餐分钟、套餐抵扣金额输入和“余额支付”按钮;预占时携带权益参数,支付后可直接进入订单详情。
|
||||
- `scripts/check-miniapp-m08-a.mjs` 扩展检查权益下单字段和余额支付入口。
|
||||
|
||||
验收:
|
||||
|
||||
- `npm run build`(`backend/`):PASS。
|
||||
- `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- `npm test`(`backend/`):PASS。
|
||||
|
||||
状态:
|
||||
|
||||
- M08-A 仍为 `PARTIAL`;真机合法域名、真实微信支付、实物开门验证,以及权益选择器的可视化列表仍待继续。
|
||||
|
||||
提交:
|
||||
|
||||
- 工程提交:`dda2b3c`
|
||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||
|
||||
## 2026-06-25 续接:权益可视化选择器
|
||||
|
||||
- 小程序房间页进入时读取 `GET /app-api/profile/benefits`,按当前门店和房间过滤可用优惠券与套餐,展示为可点选卡片,替代手工输入权益 ID。
|
||||
- 优惠券选择支持再次点击取消;套餐选择后自动填入可用分钟和可用金额,仍允许顾客调整本次使用分钟与抵扣金额。
|
||||
- 小程序下单 payload 继续只提交权益 ID、套餐使用量和幂等请求号,不接受客户端自报价格。
|
||||
- 后端预占重算时补充时长券折价:`TIME` 优惠券按当前锁价单价和券分钟折算为订单折扣,满减券、套餐金额抵扣继续走服务端 clamp。
|
||||
- `scripts/check-miniapp-m08-a.mjs` 扩展检查权益列表、选择事件、`/profile/benefits` 和余额支付入口。
|
||||
|
||||
验收:
|
||||
|
||||
- `npm run build`(`backend/`):PASS。
|
||||
- `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- `npm test`(`backend/`):PASS。
|
||||
|
||||
状态:
|
||||
|
||||
- M08-A 仍为 `PARTIAL`;真机合法域名、真实微信支付和实物开门验证待继续。
|
||||
|
||||
提交:
|
||||
|
||||
- 工程提交:`4701674`。
|
||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
# M08-B 保洁端
|
||||
|
||||
## 本次工程增量
|
||||
|
||||
- 新增 `2026062525_m08b_cleaner_tasks` 迁移,建立 `qipai_cleaning_tasks` 与 `qipai_cleaning_task_events`,覆盖 WAITING、CLAIMED、STARTED、SUBMITTED、COMPLETED、REJECTED、EXEMPT、SETTLED、CANCELLED 状态和事件追踪。
|
||||
- 权限补齐 `cleaning.task.write`、`cleaning.statistics.read`,`CLEANER`、门店管理员、租户管理员和平台管理员可获得保洁任务读写与统计能力。
|
||||
- 新增 `CleaningTaskRepository` 与 `/app-api/cleaning/*` 路由:任务大厅、我的任务、接单、开始、提交验收和个人统计均要求登录态与保洁权限。
|
||||
- 生产 `server.ts` 注入真实保洁仓储,迁移 runner 纳入 M08-B 数据库迁移。
|
||||
- 小程序新增 `pages/cleaner/tasks`,调用真实 app-api 展示任务大厅、我的任务、待结算统计,并支持接单、开始和提交照片 URL 验收。
|
||||
- `scripts/check-miniapp-m08-a.mjs` 扩展为同时检查 M08-A 顾客端与 M08-B 保洁端页面注册和真实接口调用。
|
||||
|
||||
## 2026-06-26 增量
|
||||
|
||||
- `/app-api/cleaning/tasks/:taskId/photos` 接入受控图片上传:保洁员必须登录且拥有当前任务,任务状态必须为 `STARTED` 或 `REJECTED`,上传后复用 `MediaStorage` 压缩为 WebP 并返回固定 `https://api.txyundm.cn/uploads/...` URL。
|
||||
- `/app-api/cleaning/tasks/:taskId/submit` 改为必须提交至少一张照片;`REJECTED` 任务允许再次提交,提交后清空驳回原因并记录事件。
|
||||
- 新增 `/app-api/cleaning/tasks/:taskId/rework`,保洁员可将本人 `REJECTED` 任务重新转为 `STARTED`,清空旧照片和驳回原因,进入补做流程。
|
||||
- `OrderStateRepository` 在订单状态进入 `FINISHED` 时通过同一事务调用 `CleaningTaskRepository.createForFinishedOrder`,按 `order_id` 唯一键幂等创建 `WAITING` 保洁任务并写入 `AUTO_CREATE` 事件。
|
||||
- 小程序保洁端将手工照片 URL 文本框改为相机/相册选择图片,上传成功后再提交验收;驳回任务显示原因并提供“重做”入口。
|
||||
- 修复 `marketing-benefit-service.test.mjs` 中固定订单时间导致 2026-06-26 后自然过期的问题,改为相对未来时间,避免测试随日期漂移失效。
|
||||
|
||||
## 2026-06-26 继续增量
|
||||
|
||||
- 新增管理侧保洁任务查询 `/admin-api/cleaning/tasks`,按管理员门店范围和状态筛选任务。
|
||||
- 新增管理侧指派 `/admin-api/cleaning/tasks/:taskId/assign`,指派前校验被指派用户为有效 `CLEANER` 且拥有任务门店范围。
|
||||
- 新增管理侧验收 `/admin-api/cleaning/tasks/:taskId/complete`,将 `SUBMITTED` 任务转为 `COMPLETED` 并记录事件。
|
||||
- 新增管理侧驳回 `/admin-api/cleaning/tasks/:taskId/reject`,写入驳回原因并允许保洁端进入补做链路。
|
||||
- 新增 `/admin-api/cleaning/settlement-candidates`,返回 `COMPLETED` 且未结算的任务,作为后续结算单生成前置。
|
||||
|
||||
## 2026-06-26 结算增量
|
||||
|
||||
- 新增 `2026062626_m08b_cleaning_settlements` 迁移,建立保洁结算单主表和明细表,任务明细唯一进入一个结算单。
|
||||
- 新增 `cleaning.settlement.read` 和 `cleaning.settlement.write` 权限,保洁员可读本人结算相关能力,门店/租户/平台管理员可生成和确认结算。
|
||||
- 新增 `GET /admin-api/cleaning/settlements`、`POST /admin-api/cleaning/settlements` 和 `POST /admin-api/cleaning/settlements/:id/confirm`,支持从已完成未结算任务生成 `DRAFT` 结算单并确认到 `CONFIRMED`。
|
||||
- 结算生成会锁定当前候选任务,写入结算明细,固化任务奖励金额,并把任务转为 `SETTLED`,避免同一任务重复结算。
|
||||
- 新增 `POST /admin-api/cleaning/reclaim-timeouts`,可按分钟阈值和数量限制回收超时停留在 `CLAIMED`/`STARTED` 的任务,恢复到任务大厅。
|
||||
|
||||
## 2026-06-26 多人协作增量
|
||||
|
||||
- 新增 `2026062627_m08b_cleaning_collaboration` 迁移,建立 `qipai_cleaning_task_members`,支持 `LEAD` 负责人和 `ASSIST` 协作者、成员分账金额、移除和成员维度结算时间。
|
||||
- 调整 `qipai_cleaning_settlement_items`,新增 `cleaner_user_id`,唯一键从“任务唯一”升级为“任务 + 保洁员唯一”,允许同一协作任务按成员分别进入不同保洁员结算单。
|
||||
- `CleaningTaskRepository` 在接单、指派和验收时自动维护负责人成员;重新指派会撤掉旧未结算负责人,避免同一任务出现多个有效 `LEAD`。
|
||||
- 新增管理侧成员接口:查看成员、添加/更新协作者、移除未结算协作者。协作者必须拥有 `CLEANER` 角色和任务门店范围,协作者分账总额不能超过任务奖励。
|
||||
- `GET /app-api/cleaning/tasks/mine` 与 `GET /app-api/cleaning/stats` 扩展为负责人/协作者视角;待结算金额按成员分账金额统计。
|
||||
- 结算生成改为按成员 `reward_cents` 生成明细,单个成员结算后只标记该成员 `settled_at`;所有有效成员均结清后任务才整体转为 `SETTLED`。
|
||||
|
||||
## 2026-06-27 发放闭环增量
|
||||
|
||||
- 新增 `2026062728_m08b_cleaning_payouts` 迁移,为 `qipai_cleaning_settlements` 增加 `paid_by`、`payout_channel`、`payout_reference` 和 `payout_error`,记录保洁结算发放操作人、渠道、外部流水号和失败原因。
|
||||
- 新增 `POST /admin-api/cleaning/settlements/:settlementId/paid`,仅允许管理员将 `CONFIRMED` 结算单标记为 `PAID`,写入发放渠道、流水号、发放人和 `paid_at`,并清空旧失败原因。
|
||||
- 新增 `POST /admin-api/cleaning/settlements/:settlementId/payout-failure`,仅允许对 `CONFIRMED` 结算单记录发放失败原因,保留 `CONFIRMED` 状态,支持修复外部付款后重试。
|
||||
- 结算单查询返回新增发放字段,管理端后续可以直接展示发放状态、渠道流水和失败原因。
|
||||
|
||||
## 2026-06-27 微信转账适配增量
|
||||
|
||||
- `WechatPayClient` 新增微信支付商家转账请求能力,按商户转账单号、转账场景、openid、金额、备注、通知地址和场景报备信息发起 `POST /v3/fund-app/mch-transfer/transfer-bills`,并解析微信转账单号、状态、失败原因和 `package_info`。
|
||||
- 新增 `CleaningPayoutService`,从结算单解析租户、门店、保洁员、金额和状态,复用门店/全局微信收款账户与环境凭据,校验商户号一致、收款账户已授权、保洁员存在微信 openid,再执行真实微信转账或测试 Mock。
|
||||
- 新增 `QIPAI_CLEANING_PAYOUT_MOCK_ENABLED`,仅非生产环境允许 Mock 发放;生产环境不会因为误配打开 Mock。
|
||||
- 新增 `2026062729_m08b_cleaning_transfer_state` 迁移,为保洁结算单增加 `payout_state` 和 `payout_package_info`,记录 `SUCCESS`、`FAIL`、`WAIT_USER_CONFIRM`、`PROCESSING` 等微信转账中间态。
|
||||
- 新增 `POST /admin-api/cleaning/settlements/:settlementId/wechat-transfer`,管理员可对 `CONFIRMED` 结算单发起微信转账;成功直接落 `PAID`,失败记录失败原因,待用户确认/处理中则保留 `CONFIRMED` 并写入中间态和微信 package 信息,便于后续查询或回调闭环。
|
||||
|
||||
## 2026-06-29 微信转账回调与轮询增量
|
||||
|
||||
- `WechatPayClient` 新增商户单号查询微信商家转账接口,按 `out_bill_no` 调用微信查询 API 并解析商户号、微信转账单号、状态、失败原因和转账金额。
|
||||
- `CleaningPayoutService.syncWechatTransfer` 新增主动轮询闭环:管理员可对已发起微信转账的 `CONFIRMED` 结算单同步微信状态;`SUCCESS` 自动标记 `PAID`,`FAIL` 记录失败原因,其它中间态继续保留 `CONFIRMED` 并更新 `payout_state`。
|
||||
- `CleaningPayoutService.processWechatTransferNotification` 新增微信转账通知处理:按微信平台证书序列号选择凭据、验签解密原始通知、校验商户号,再通过商户单号或微信转账单号定位结算单并写回成功、失败或中间态。
|
||||
- 新增 `POST /admin-api/cleaning/settlements/:settlementId/wechat-transfer/sync`,用于后台手动同步或后续定时轮询调用。
|
||||
- 新增 `POST /app-api/cleaning/wechat-transfer/notify`,免登录接收微信转账回调,使用流式 `preParsing` 捕获原始 body,避免验签因 JSON 重序列化失效。
|
||||
|
||||
## 2026-06-29 保洁管理后台增量
|
||||
|
||||
- `admin/` 从空目录升级为 Vue3 + Vite + TypeScript + Element Plus 后台工程,构建基址固定为 `/admin/`,生产后台 API 固定为 `https://api.txyundm.cn/admin-api`。
|
||||
- 新增保洁运营首屏,支持后台访问令牌本地保存、任务/结算双标签、待验收/完成/待发放/本页金额概览,并适配桌面、平板和手机浏览器。
|
||||
- 新增保洁任务管理面板:按状态筛选任务,查看门店/房间/保洁员/成员/奖励/照片数量,支持指派、验收、驳回和超时回收。
|
||||
- 新增保洁结算管理面板:按状态筛选结算单,支持生成结算单、确认结算、发起微信转账、同步微信转账状态和记录发放失败。
|
||||
- 新增 `scripts/check-admin-m08-b.mjs` 并接入 Windows `test-all.ps1`,静态检查后台依赖、固定域名、构建基址、M08-B API 封装和关键控件。
|
||||
|
||||
## 2026-06-29 微信转账预检增量
|
||||
|
||||
- `CleaningPayoutService` 新增 `preflightWechatTransfer`,不触发真实转账,仅读取结算单、收款账户、微信支付凭据元数据和保洁员 openid,返回脱敏商户号、凭据引用、场景 ID、报备项数量、通知地址、平台证书数量和逐项 PASS/WARN/FAIL。
|
||||
- 新增 `GET /admin-api/cleaning/settlements/:settlementId/wechat-transfer/preflight`,管理员可在真实商户转账前检查结算单状态、金额、收款账户授权、商户号匹配、场景报备配置、通知证书和保洁员 openid。
|
||||
- 后台结算面板新增“预检”操作和结果弹窗,展示真实转账前置条件是否满足,帮助现场联调前明确缺失项。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查微信转账预检 API 封装和后台控件。
|
||||
|
||||
## 2026-06-30 后台协作与结算详情增量
|
||||
|
||||
- 新增 `GET /admin-api/cleaning/settlements/:settlementId`,按租户和门店数据范围返回结算单主信息与固化结算明细,包含任务号、订单号、门店、房间、保洁员、成员分账金额和完成时间。
|
||||
- 后台任务面板新增“成员”入口,可查看负责人/协作者、分账金额和结算状态,并支持添加/更新协作者与移除未结算协作者。
|
||||
- 后台结算面板新增“详情”入口,可查看结算单任务明细、转账状态、外部流水、失败原因和备注,避免管理员只能从列表猜测结算口径。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查协作者管理、结算详情和对应 API 封装。
|
||||
|
||||
## 2026-06-30 统计明细增量
|
||||
|
||||
- 新增 `GET /admin-api/cleaning/statistics`,支持按开始日期、结束日期和门店 ID 筛选,按登录管理员的租户与门店数据范围裁剪统计。
|
||||
- 管理侧统计返回任务状态分布、门店任务明细、结算状态金额和成员分账排行,明确待验收、待结算、待发放、已发放和成员分账口径。
|
||||
- 后台新增“统计”标签页,展示任务状态、结算口径、门店明细和成员分账;首屏四个指标改为使用管理侧统计接口,不再依赖当前分页粗略汇总。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查统计标签页、统计 API 封装和关键统计展示控件。
|
||||
|
||||
## 2026-06-30 保洁员资料管理增量
|
||||
|
||||
- `/admin-api/users` 新增 `role` 查询参数,支持后台按 `CLEANER`、`STAFF`、`STORE_ADMIN` 和 `TENANT_ADMIN` 筛选员工,继续复用员工管理权限和门店数据范围。
|
||||
- 后台统计页新增“今日/近7天/本月”快捷周期,自动生成闭区间起始日和次日/次月结束日并刷新管理统计。
|
||||
- 后台新增“保洁员”标签页,支持按状态和关键词筛选保洁员,查看门店范围、备注、最近登录和脱敏 IP。
|
||||
- 后台保洁员资料管理接入已有员工 API,支持创建 `CLEANER`、编辑姓名/门店范围/备注、启用/停用和重置会话。
|
||||
|
||||
## 2026-06-30 统计趋势增量
|
||||
|
||||
- 管理侧 `GET /admin-api/cleaning/statistics` 在原有状态分布、门店明细、结算口径和成员分账排行基础上追加 `trend`,按任务创建日期聚合每日任务总数、待验收、完成、驳回、奖励和已发放金额。
|
||||
- 后台统计页新增“每日趋势”区块,使用响应式条形行展示每日任务量,并并列呈现待验、完成、驳回、奖励和发放金额,方便运营按周期筛选后查看波动。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查每日趋势控件和发放金额字段,避免后续后台改动丢失趋势展示。
|
||||
|
||||
## 2026-06-30 保洁员资料细节增量
|
||||
|
||||
- `/admin-api/users` 列表返回新增 `avatarUrl` 与 `wechatMiniappBound`,后台可判断保洁员是否已绑定微信小程序 openid,为真实微信转账资料预检提供更直观的人员资料状态。
|
||||
- 后台“保洁员”列表新增资料标签,展示“已绑微信/未绑微信”和“资料完整/待补资料”,资料完整度按微信绑定、门店范围和姓名综合判断。
|
||||
- 后台保洁员编辑弹窗补手机号维护,提交时透传 `phone` 到既有员工更新接口,继续保留 `CLEANER` 角色和门店范围约束。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查微信绑定、资料完整度和保洁员资料标签,`backend/tests/user-management.test.mjs` 覆盖手机号更新透传。
|
||||
|
||||
## 2026-06-30 保洁员选择器增量
|
||||
|
||||
- 后台任务指派弹窗从手填“保洁员用户 ID”升级为可搜索保洁员选择器,选项展示姓名、用户 ID、脱敏手机号、门店范围和微信绑定状态,降低现场运营输错 ID 的风险。
|
||||
- 后台任务协作者管理同样接入保洁员选择器,添加/更新协作者前先拦截空选择,并继续保留成员分账金额和备注录入。
|
||||
- 后台生成保洁结算单接入保洁员选择器,管理员从已加载的 `CLEANER` 列表选择人员后生成结算单,门店 ID 仍作为可选过滤条件。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查任务、结算组件中的 `el-select`、`cleanerLabel` 以及选择器样式,避免后续回退到纯 ID 输入。
|
||||
|
||||
## 2026-06-30 任务结算筛选增量
|
||||
|
||||
- 管理侧 `GET /admin-api/cleaning/tasks` 新增 `storeId` 与 `cleanerUserId` 查询参数;保洁员筛选同时匹配负责人和未移除协作者,继续受管理员门店数据范围裁剪。
|
||||
- 管理侧 `GET /admin-api/cleaning/settlements` 新增 `storeId` 与 `cleanerUserId` 查询参数,便于财务按门店、保洁员和结算状态定位待确认、待发放或异常结算单。
|
||||
- 后台任务列表和结算列表新增门店 ID 输入、保洁员下拉和筛选按钮,筛选条件会随分页、状态切换和刷新进入真实 admin-api 请求。
|
||||
- `backend/tests/cleaning-route.test.mjs` 覆盖任务/结算筛选参数透传;`scripts/check-admin-m08-b.mjs` 扩展检查筛选控件、API 参数和样式。
|
||||
|
||||
## 2026-06-30 任务详情照片预览增量
|
||||
|
||||
- 后台任务列表新增“详情”入口,管理员可在弹窗中查看任务号、状态、奖励、保洁员、门店房间、订单、保洁要求和驳回原因。
|
||||
- 任务详情弹窗新增领取、开始、提交和完成时间线,减少验收/驳回时在列表列之间来回查找。
|
||||
- 任务详情弹窗支持保洁照片缩略图与大图预览;无照片时显示空状态,便于待验收任务快速确认现场材料。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查任务详情弹窗、照片预览和时间线控件。
|
||||
|
||||
## 验收结果
|
||||
|
||||
- `npm run build`(`backend/`):PASS。
|
||||
- `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- `node tests/cleaning-route.test.mjs`:PASS。
|
||||
- `node tests/migration-contract.test.mjs`:PASS。
|
||||
- `node tests/migration-runner.test.mjs`:PASS。
|
||||
- `npm test`(`backend/`):PASS。
|
||||
- 2026-06-26 追加验证:
|
||||
- `node tests/cleaning-route.test.mjs`:PASS,覆盖 `rework` 和照片上传路由。
|
||||
- `npm test -- --runInBand`(`backend/`):PASS。
|
||||
- `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- 继续增量后 `npm test -- --runInBand`(`backend/`):PASS,覆盖管理侧指派、验收、驳回和待结算候选路由。
|
||||
- 结算增量后 `npm test -- --runInBand`(`backend/`):PASS,覆盖结算迁移、结算单路由和超时回收路由。
|
||||
- 结算增量后 `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- 多人协作增量后 `npm run build`(`backend/`):PASS。
|
||||
- 多人协作增量后 `node tests/migration-contract.test.mjs`:PASS。
|
||||
- 多人协作增量后 `node tests/migration-runner.test.mjs`:PASS。
|
||||
- 多人协作增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖成员查看、添加和移除路由。
|
||||
- 发放闭环增量后 `npm run build`(`backend/`):PASS。
|
||||
- 发放闭环增量后 `node tests/migration-contract.test.mjs`:PASS。
|
||||
- 发放闭环增量后 `node tests/migration-runner.test.mjs`:PASS。
|
||||
- 发放闭环增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖结算发放成功和发放失败记录路由。
|
||||
- 微信转账适配增量后 `npm run build`(`backend/`):PASS。
|
||||
- 微信转账适配增量后 `node tests/wechat-pay.test.mjs`:PASS,覆盖商家转账请求签名与载荷。
|
||||
- 微信转账适配增量后 `node tests/cleaning-payout-service.test.mjs`:PASS,覆盖真实 API 成功、失败、待确认和 Mock 门禁。
|
||||
- 微信转账适配增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖微信转账路由。
|
||||
- 微信转账适配增量后 `node tests/migration-contract.test.mjs`:PASS。
|
||||
- 微信转账适配增量后 `node tests/migration-runner.test.mjs`:PASS。
|
||||
- 微信转账适配增量后 `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- 微信转账适配增量后 `npm test`(`backend/`):PASS。
|
||||
- 微信转账回调与轮询增量后 `npm run build`(`backend/`):PASS。
|
||||
- 微信转账回调与轮询增量后 `node tests/wechat-pay.test.mjs`:PASS,覆盖商户单号查询转账单请求。
|
||||
- 微信转账回调与轮询增量后 `node tests/cleaning-payout-service.test.mjs`:PASS,覆盖主动同步成功/失败/处理中和微信通知成功落库。
|
||||
- 微信转账回调与轮询增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖后台同步路由和免登录微信通知路由。
|
||||
- 微信转账回调与轮询增量后 `npm test`(`backend/`):PASS。
|
||||
- 保洁管理后台增量后 `npm run build`(`admin/`):PASS。
|
||||
- 保洁管理后台增量后 `npm audit --omit=dev`(`admin/`):PASS,0 vulnerabilities。
|
||||
- 保洁管理后台增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 微信转账预检增量后 `npm run build`(`backend/`):PASS。
|
||||
- 微信转账预检增量后 `node tests/cleaning-payout-service.test.mjs`:PASS,覆盖配置齐全、缺场景报备和缺 openid 的预检结果。
|
||||
- 微信转账预检增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖后台预检路由。
|
||||
- 微信转账预检增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 微信转账预检增量后 `npm run build`(`admin/`):PASS。
|
||||
- 后台协作与结算详情增量后 `npm run build`(`backend/`):PASS。
|
||||
- 后台协作与结算详情增量后 `npm run build`(`admin/`):PASS。
|
||||
- 后台协作与结算详情增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖结算单详情路由。
|
||||
- 后台协作与结算详情增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 后台协作与结算详情增量后 `npm test`(`backend/`):PASS。
|
||||
- 统计明细增量后 `npm run build`(`backend/`):PASS。
|
||||
- 统计明细增量后 `npm run build`(`admin/`):PASS。
|
||||
- 统计明细增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖管理侧统计路由。
|
||||
- 统计明细增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 统计明细增量后 `npm test`(`backend/`):PASS。
|
||||
- 保洁员资料管理增量后 `npm run build`(`backend/`):PASS。
|
||||
- 保洁员资料管理增量后 `npm run build`(`admin/`):PASS。
|
||||
- 保洁员资料管理增量后 `node tests/user-management.test.mjs`:PASS,覆盖 `role=CLEANER` 员工筛选。
|
||||
- 保洁员资料管理增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 统计趋势增量后 `npm run build`(`backend/`):PASS。
|
||||
- 统计趋势增量后 `npm run build`(`admin/`):PASS。
|
||||
- 统计趋势增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖管理统计趋势字段透传。
|
||||
- 统计趋势增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 保洁员资料细节增量后 `npm run build`(`backend/`):PASS。
|
||||
- 保洁员资料细节增量后 `npm run build`(`admin/`):PASS。
|
||||
- 保洁员资料细节增量后 `node tests/user-management.test.mjs`:PASS,覆盖手机号更新透传。
|
||||
- 保洁员资料细节增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 保洁员选择器增量后 `npm run build`(`admin/`):PASS。
|
||||
- 保洁员选择器增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 任务结算筛选增量后 `npm run build`(`backend/`):PASS。
|
||||
- 任务结算筛选增量后 `npm run build`(`admin/`):PASS。
|
||||
- 任务结算筛选增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖任务/结算筛选参数透传。
|
||||
- 任务结算筛选增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 任务详情照片预览增量后 `npm run build`(`admin/`):PASS。
|
||||
- 任务详情照片预览增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 统计按保洁员筛选增量后 `npm run build`(`backend/`):PASS。
|
||||
- 统计按保洁员筛选增量后 `npm run build`(`admin/`):PASS。
|
||||
- 统计按保洁员筛选增量后 `node backend/tests/cleaning-route.test.mjs`:PASS,覆盖管理统计 `cleanerUserId` 参数透传。
|
||||
- 统计按保洁员筛选增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 待结算候选预览增量后 `npm run build`(`backend/`):PASS。
|
||||
- 待结算候选预览增量后 `npm run build`(`admin/`):PASS。
|
||||
- 待结算候选预览增量后 `node backend/tests/cleaning-route.test.mjs`:PASS,覆盖候选接口门店/保洁员筛选参数透传。
|
||||
- 待结算候选预览增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 结算候选导出增量后 `npm run build`(`admin/`):PASS。
|
||||
- 结算候选导出增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 任务事件流水增量后 `npm run build`(`backend/`):PASS。
|
||||
- 任务事件流水增量后 `npm run build`(`admin/`):PASS。
|
||||
- 任务事件流水增量后 `node backend/tests/cleaning-route.test.mjs`:PASS,覆盖任务事件流水路由。
|
||||
- 任务事件流水增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 运营待处理入口增量后 `npm run build`(`admin/`):PASS。
|
||||
- 运营待处理入口增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 转账状态筛选增量后 `npm run build`(`backend/`):PASS。
|
||||
- 转账状态筛选增量后 `node backend/tests/cleaning-route.test.mjs`:PASS,覆盖 `payoutState` 参数透传。
|
||||
- 转账状态筛选增量后 `npm run build`(`admin/`):PASS。
|
||||
- 转账状态筛选增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
- 微信确认参数展示增量后 `npm run build`(`admin/`):PASS。
|
||||
- 微信确认参数展示增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||
|
||||
## 2026-06-30 统计按保洁员筛选增量
|
||||
|
||||
- 后端 `GET /admin-api/cleaning/statistics` 新增 `cleanerUserId` 查询参数,任务统计按负责人或有效协作者过滤,结算统计和每日已发放金额按结算单保洁员过滤。
|
||||
- 后台统计页新增可搜索保洁员选择器,与任务列表和结算列表的筛选口径保持一致,支持按单个保洁员定位待验收、待结算、已发放和每日趋势。
|
||||
- `backend/tests/cleaning-route.test.mjs` 补充管理统计筛选参数透传断言,`scripts/check-admin-m08-b.mjs` 补充统计页保洁员筛选控件检查。
|
||||
|
||||
## 2026-06-30 待结算候选预览增量
|
||||
|
||||
- 后端 `GET /admin-api/cleaning/settlement-candidates` 新增 `storeId` 和 `cleanerUserId` 查询参数,候选任务继续限定为 `COMPLETED` 且未结算,保洁员筛选覆盖负责人和未移除、未结算协作者。
|
||||
- 后台结算页新增“候选”入口,可按当前门店/保洁员筛选预览待结算任务、当前页金额、房间、负责人和完成时间,并可将当前筛选带入生成结算单弹窗。
|
||||
- `backend/tests/cleaning-route.test.mjs` 补充候选接口筛选参数透传断言,`scripts/check-admin-m08-b.mjs` 补充后台候选预览控件检查。
|
||||
|
||||
## 2026-06-30 结算候选导出增量
|
||||
|
||||
- 后台结算页新增当前结算列表 CSV 导出,包含结算单号、状态、保洁员、门店、任务数、金额、转账状态、外部流水、失败原因和创建时间。
|
||||
- 待结算候选弹窗新增当前页候选任务 CSV 导出,包含任务号、订单号、门店、房间、保洁员 ID、金额和完成时间,便于财务或线下核对。
|
||||
- 导出实现使用浏览器 Blob 和 UTF-8 BOM,未新增第三方依赖;`scripts/check-admin-m08-b.mjs` 补充导出入口检查。
|
||||
|
||||
## 2026-06-30 任务事件流水增量
|
||||
|
||||
- 后端新增 `GET /admin-api/cleaning/tasks/:taskId/events`,按租户和管理员门店范围返回任务最近 50 条事件流水,覆盖动作、from/to 状态、操作人、traceId、备注和创建时间。
|
||||
- 后台任务详情弹窗新增“操作流水”表格,验收、驳回、回收、协作者变更、结算成员等操作可从任务详情直接追溯。
|
||||
- `backend/tests/cleaning-route.test.mjs` 补充任务事件流水路由断言,`scripts/check-admin-m08-b.mjs` 补充后台任务详情流水检查。
|
||||
|
||||
## 2026-07-02 批量验收驳回增量
|
||||
|
||||
- 后台任务列表新增多选列,仅允许选择 `SUBMITTED` 待验收任务,避免把进行中、已完成或已结算任务误纳入批量处理。
|
||||
- 后台任务工具栏新增“批量验收”和“批量驳回”;批量验收复用现有单任务验收 API 顺序执行,批量驳回要求管理员输入统一驳回原因后再提交。
|
||||
- 父级工作台新增 `handleCompleteMany` 与 `handleRejectMany`,批量处理完成后同步刷新任务列表和统计概览,保持待验收、完成、待结算金额实时更新。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查批量验收、批量驳回、已选择待验收任务过滤和父子组件事件,避免后续后台改动丢失批量运营能力。
|
||||
|
||||
## 2026-07-02 任务详情协作分账增量
|
||||
|
||||
- 后台任务详情弹窗新增“协作分账”区块,直接展示负责人和协作者、成员角色、分账金额与结算状态。
|
||||
- 打开任务详情时并行加载任务事件流水和成员列表,管理员验收或驳回前可以在同一弹窗核对照片、操作历史和成员分账口径。
|
||||
- 该增量复用既有 `GET /admin-api/cleaning/tasks/:taskId/members`,未新增数据库表或后端路由。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查协作分账展示、成员列表加载和 loading 状态,避免详情页回退为只看流水与照片。
|
||||
|
||||
## 2026-07-02 结算批量同步增量
|
||||
|
||||
- 后台结算列表新增多选列,仅允许选择 `CONFIRMED` 待发放结算单,避免误同步草稿、已发放或已取消单据。
|
||||
- 结算工具栏新增“批量同步”,对已选择结算单顺序调用既有微信转账同步 API,批量完成后刷新结算列表和统计概览。
|
||||
- 父级工作台新增 `handleSyncTransferMany`,复用单笔同步流程和错误处理,不新增后端路由或数据库字段。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查批量同步按钮、可同步结算单过滤和父子组件事件,防止后续结算面板回退到只能逐行同步。
|
||||
|
||||
## 2026-07-05 运营待处理入口增量
|
||||
|
||||
- 后台保洁运营首屏新增“运营待处理”快捷入口,复用管理侧统计结果展示待验收任务、驳回补做、待确认结算和待发放金额。
|
||||
- 快捷入口可直接切换到任务或结算标签页,并自动应用 `SUBMITTED`、`REJECTED`、`DRAFT` 或 `CONFIRMED` 状态筛选,减少管理员在多个标签和分段控件之间重复定位。
|
||||
- 新增 `.action-board` 与 `.action-tile` 响应式样式,桌面四列、平板两列、手机单列,保持保洁工作台在桌面、平板和手机浏览器下可扫描。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查运营待处理入口、跳转函数、待确认结算统计和响应式样式,防止后续后台改动丢失快捷处置能力。
|
||||
|
||||
## 2026-07-05 转账状态筛选增量
|
||||
|
||||
- 后端 `GET /admin-api/cleaning/settlements` 新增 `payoutState` 查询参数,支持 `NONE`、`PROCESSING`、`WAIT_USER_CONFIRM`、`SUCCESS` 和 `FAIL`,用于真实商户联调时定位未发起、处理中、待用户确认、成功和失败结算单。
|
||||
- 结算仓储新增转账状态 SQL 条件,其中 `NONE` 映射为空 `payout_state`,其它状态使用精确匹配,继续叠加租户、门店范围、结算状态、门店和保洁员筛选。
|
||||
- 后台结算列表新增“转账状态”下拉筛选,请求封装会把筛选条件传入 admin-api;首屏快捷跳转待确认/待发放结算时自动清空旧转账状态筛选,避免误叠加。
|
||||
- `backend/tests/cleaning-route.test.mjs` 覆盖 `payoutState=FAIL` 透传,`scripts/check-admin-m08-b.mjs` 覆盖后台筛选控件和 API 参数。
|
||||
|
||||
## 2026-07-05 微信确认参数展示增量
|
||||
|
||||
- 后台结算单详情新增“微信确认参数”字段,直接展示已落库的 `payoutPackageInfo`,方便管理员处理 `WAIT_USER_CONFIRM` 场景时核对微信侧确认参数。
|
||||
- 结算列表 CSV 导出新增“微信确认参数”列,导出时同步带出 `payoutPackageInfo`,便于财务或现场联调人员离线排查待确认转账。
|
||||
- 本增量复用既有结算详情和列表返回字段,未新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `payoutPackageInfo` 和“微信确认参数”展示,避免后续结算详情或导出回退。
|
||||
|
||||
## 2026-07-05 微信预检资料展示增量
|
||||
|
||||
- 后台微信转账预检弹窗新增真实商户资料摘要,展示凭据引用、授权状态、账户范围、AppID、商户证书序列号、场景报备、通知 URL、平台证书数量和保洁员 OpenID 状态。
|
||||
- 新增 `configuredText` 将凭据类布尔字段转成运营可读的“已配置/缺失”,减少现场联调时从检查项表格里反复推断配置缺口。
|
||||
- 本增量复用既有 `preflightWechatTransfer` 返回结构,未新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查预检资料字段和格式化函数,避免后续结算面板回退为只展示检查项。
|
||||
|
||||
## 2026-07-05 微信预检导出增量
|
||||
|
||||
- 后台微信转账预检弹窗新增“导出预检”按钮,可将当前结算单的 ready 状态、结算摘要、脱敏商户/凭据、授权状态、场景、通知 URL、平台证书、保洁员 OpenID 和全部检查项导出为 CSV。
|
||||
- 导出文件使用 `cleaning-transfer-preflight-{settlementNo}-{timestamp}.csv` 命名,复用既有 UTF-8 BOM CSV 下载工具,便于真实商户现场联调时留存和转交排查证据。
|
||||
- 本增量未新增后端路由、数据库字段或第三方依赖,继续复用既有 `preflightWechatTransfer` 结果。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查“导出预检”、`exportPreflight` 和导出文件名前缀,避免后续结算面板丢失现场联调证据导出能力。
|
||||
|
||||
## 2026-07-05 微信转账预检拦截增量
|
||||
|
||||
- 后台点击“微信转账”时会自动调用 `preflightWechatTransfer`,在转账弹窗内展示当前真实转账前置条件检查结果。
|
||||
- API 模式下预检未完成或存在 `FAIL` 阻断项时禁用“发起”按钮;Mock 模式仍可用于非生产演练,避免真实商户现场误触发不可满足条件的转账请求。
|
||||
- 新增 `transferCanSubmit`、`transferPreflightTitle` 和 `transfer-preflight-alert`,将阻断项数量直接展示在转账弹窗里,减少管理员在预检弹窗和转账弹窗之间来回切换。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查转账前预检标题、禁用条件、loading 状态和弹窗样式,避免后续回退为无前置拦截的直接发起。
|
||||
|
||||
## 2026-07-05 任务列表导出增量
|
||||
|
||||
- 后台任务列表新增当前筛选页 CSV 导出入口,管理员可导出任务号、状态、门店、房间、订单号、保洁员 ID、协作人数、奖励金额、照片数、提交/完成时间和驳回原因。
|
||||
- 导出使用浏览器 Blob 和 UTF-8 BOM,不新增第三方依赖;文件名使用 `cleaning-tasks-{timestamp}.csv`,便于运营按当前筛选结果留存待验收、驳回补做或异常任务证据。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查任务导出按钮、`exportTasks`、CSV 文件名前缀和照片数量字段,避免后续后台改动丢失任务导出能力。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS;`node scripts/check-miniapp-m08-a.mjs` PASS;`check-status-docs.ps1` PASS;`check-secrets.ps1` PASS;`check-large-files.ps1` 仅保留既有 `参考\mazongjian-server.xjar` 超 100MB 警告;`git diff --check` PASS。
|
||||
|
||||
## 2026-07-05 任务详情导出增量
|
||||
|
||||
- 后台任务详情弹窗新增“导出详情”,可将单个任务摘要、协作成员分账、最近操作流水和保洁照片链接导出为 CSV。
|
||||
- 导出文件名使用 `cleaning-task-detail-{taskNo}-{timestamp}.csv`,便于管理员在验收、驳回、协作分账争议或现场复盘时留存单任务证据。
|
||||
- 本增量复用任务详情已加载的成员、流水和照片数据,未新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查任务详情导出入口、`exportTaskDetail` 和详情导出文件名前缀。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-05 现场联调清单增量
|
||||
|
||||
- 后台新增“联调”标签页,提供真实商户、微信合法域名、保洁转账和硬件开门/控电的现场联调检查项,覆盖 M08-B 当前真实商户和现场硬件阻塞项。
|
||||
- 联调面板支持按商户、微信、转账、硬件分类筛选,记录每项状态、负责人和结果备注,并在浏览器本地保存,不把未完成的现场结果写入生产状态。
|
||||
- 联调清单可导出为 `cleaning-field-handoff-{timestamp}.csv`,包含检查项、期望、状态、负责人、结果记录和证据要求,便于现场联调留痕和转交排查。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查联调标签页、本地状态键、关键检查项和导出入口。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-05 联调状态筛选增量
|
||||
|
||||
- 后台联调面板新增状态筛选,支持按全部、待处理、通过、阻断和不适用过滤现场检查项。
|
||||
- 现场联调人员可在商户/微信/转账/硬件分类之外快速聚焦阻断项或待处理项,减少真实商户和硬件现场排查时的人工翻找。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `statusFilter`、全部状态选项和 `.handoff-status-select` 样式。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-05 联调批量标记增量
|
||||
|
||||
- 后台联调面板新增“批量标记”,可对当前分类和状态筛选出的检查项统一设置待处理、通过、阻断或不适用。
|
||||
- 批量标记支持填写负责人和结果记录;留空时不覆盖原负责人或原备注,便于现场把一组阻断项统一分派,或把一批已验项统一标记通过。
|
||||
- 本增量继续使用浏览器本地存储,不把未完成现场结果写入生产状态,也不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查批量标记入口、`bulkForm`、`applyBulkMark` 和批量表单样式。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-05 联调阻断摘要增量
|
||||
|
||||
- 后台联调面板新增“阻断摘要”,当现场检查项被标记为 `BLOCKED` 时自动汇总类别、检查项、负责人和阻断备注。
|
||||
- 新增“复制摘要”按钮,使用浏览器剪贴板把阻断项整理成多行文本,便于发送给微信商户、硬件厂商或现场负责人;浏览器禁用剪贴板时提示改用 CSV 导出。
|
||||
- 本增量继续使用本地联调记录,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查阻断摘要、`blockedItems`、`copyBlockedSummary` 和剪贴板调用。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-05 联调场次信息增量
|
||||
|
||||
- 后台联调面板新增联调批次、现场/门店、现场负责人和执行时间字段,记录一次真实商户或硬件现场执行的基本场次信息。
|
||||
- 场次信息使用浏览器本地存储独立保存,并随联调 CSV 一起导出;复制阻断摘要时也会带上批次、现场、负责人和执行时间,便于跨团队转交排查。
|
||||
- 本增量不新增后端路由、数据库字段或第三方依赖,仍避免把未完成现场结果写入生产状态。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查场次本地存储键、`sessionMeta`、`persistSessionMeta` 和场次表单样式。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-05 联调快照增量
|
||||
|
||||
- 后台联调面板新增 JSON 快照导出,包含联调场次信息和全部检查项状态、负责人、结果记录,文件名使用 `cleaning-field-handoff-snapshot-{timestamp}.json`。
|
||||
- 新增 JSON 快照导入,校验固定 schema `qipai.cleaning.field-handoff.snapshot.v1` 后恢复本地联调场次和检查项记录;未知检查项会被忽略,避免旧快照污染新清单。
|
||||
- 本增量继续仅使用浏览器本地状态,不新增后端路由、数据库字段或第三方依赖,便于现场换电脑、复盘和转交联调记录。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查快照导出、导入、schema 和隐藏文件输入样式。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 联调分类进度增量
|
||||
|
||||
- 后台联调面板新增分类进度视图,按商户、微信、转账和硬件分别展示通过数、总数、阻断、待处理、不适用以及通过率进度条。
|
||||
- 分类中存在阻断项时进度条显示异常状态,全部通过时显示成功状态,便于现场负责人快速判断是哪一类外部条件仍未满足。
|
||||
- 本增量复用既有本地联调记录,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `categoryProgress`、`passPercent` 和分类进度样式。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 联调验收结论增量
|
||||
|
||||
- 后台联调面板新增自动验收结论:当所有检查项均为通过或不适用时显示“可进入现场验收收口”,否则显示仍有多少待处理和阻断项。
|
||||
- 新增“复制结论”按钮,将联调批次、现场、负责人、执行时间、结论和统计复制为文本,便于现场验收前后快速同步。
|
||||
- 本增量继续复用本地联调记录,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `handoffReady`、`copyVerdict`、结论提示和样式。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 联调更新时间增量
|
||||
|
||||
- 后台联调面板新增“最后更新”展示,场次信息变更、单项状态/负责人/备注编辑、批量标记、重置和快照导入都会刷新更新时间。
|
||||
- CSV 导出、阻断摘要、验收结论和 JSON 快照均带出最后更新时间,便于现场判断当前记录是否来自最新一轮联调。
|
||||
- 本增量继续复用浏览器本地状态,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `updatedAt`、最后更新字段和本地时间格式化函数。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 联调证据记录增量
|
||||
|
||||
- 后台联调面板为每个真实商户、微信、转账和硬件检查项新增“证据记录”输入,可单独登记截图编号、流水号、设备回包或文件链接,不再只能混入结果备注。
|
||||
- 顶部进度新增证据记录数,验收结论复制、阻断摘要复制、CSV 导出和 JSON 快照均带出实际证据记录,便于现场复盘和交接。
|
||||
- 快照导入和本地草稿读取兼容旧数据,缺少 `evidenceRef` 时自动补空,不影响既有联调记录恢复。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `evidenceRef`、证据记录字段、证据计数和草稿归一化函数。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 联调待补证据增量
|
||||
|
||||
- 后台联调面板新增“待补证据”统计和清单,自动汇总除“不适用”外尚未填写证据记录的现场检查项。
|
||||
- 新增“复制证据清单”按钮,将联调批次、现场、负责人、执行时间、最后更新和每个缺证据项的应留证据复制为文本,便于现场负责人集中追补。
|
||||
- 验收结论复制和 CSV 导出同步带出待补证据数,避免状态已处理但证据未归档时误判为可交接。
|
||||
- 本增量继续复用本地联调记录,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `evidenceMissingItems`、`evidenceMissingCount`、`copyEvidenceSummary` 和复制成功提示。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 联调交接报告增量
|
||||
|
||||
- 后台联调面板新增“复制报告”和“报告”导出按钮,生成保洁现场联调交接报告文本。
|
||||
- 报告汇总联调场次、负责人、执行时间、最后更新、验收结论、总进度、分类进度、阻断项、待补证据和全部检查项明细。
|
||||
- 报告明细会同时带出状态、负责人、结果记录、应留证据和证据记录,方便真实商户/硬件现场收口后直接归档。
|
||||
- 本增量继续复用本地联调记录,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `copyHandoffReport`、`exportHandoffReport`、`buildHandoffReport`、`formatReportItem`、`statusText` 和导出文件名前缀。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 联调收口签署增量
|
||||
|
||||
- 后台联调场次信息新增验收人、收口结论和下一步字段,用于记录真实商户或硬件现场执行后的确认人与后续动作。
|
||||
- 阻断摘要、待补证据清单、验收结论、CSV 导出、JSON 快照和交接报告均带出新增签署字段,便于现场交接后追责和复盘。
|
||||
- 快照导入和本地场次读取兼容旧数据,缺少新增签署字段时自动补空。
|
||||
- 本增量继续复用本地联调记录,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `acceptor`、`closeoutVerdict`、`nextAction` 和 `createEmptySessionMeta`。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 联调收口门禁增量
|
||||
|
||||
- 后台联调面板新增收口归档门禁,基于检查项状态、待补证据、验收人和收口结论自动判断“可归档”或“待补齐”。
|
||||
- 新增“复制门禁”按钮,单独复制联调批次、现场、归档门禁、缺口、验收人、收口结论、下一步和最后更新时间,便于现场收口前同步。
|
||||
- 验收结论、CSV 导出和交接报告同步带出归档门禁,避免检查项已处理但证据或签署信息未补齐时误归档。
|
||||
- 本增量继续复用本地联调记录,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `closeoutReady`、`missingCloseoutFields`、`copyCloseoutGate`、门禁提示和复制成功提示。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 保洁员列表导出增量
|
||||
|
||||
- 后台保洁员管理面板新增“导出”按钮,可将当前已加载筛选页导出为 `cleaning-cleaners-{timestamp}.csv`。
|
||||
- 导出字段包含保洁员 ID、姓名、状态、微信绑定、资料完整度、门店范围、脱敏手机号、最近登录、最近 IP、注册时间和备注,便于运营核对人员资料与门店授权。
|
||||
- CSV 导出复用前端本地数据和 BOM 编码,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `exportCleaners`、导出文件名前缀、`downloadCsv` 和 `csvCell`。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 保洁统计导出增量
|
||||
|
||||
- 后台统计页新增“导出”按钮,可将当前筛选条件下的统计结果导出为 `cleaning-statistics-{timestamp}.csv`。
|
||||
- 导出内容按筛选条件、汇总、任务状态、结算口径、每日趋势、门店明细和成员分账分区,便于运营离线复核统计口径。
|
||||
- CSV 导出复用前端统计接口返回的数据和 BOM 编码,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `exportStatistics`、导出文件名前缀、`downloadCsv` 和 `csvCell`。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 任务流水导出增量
|
||||
|
||||
- 后台任务详情的操作流水区块新增“导出流水”按钮,可将当前任务最近操作流水导出为 `cleaning-task-events-{taskNo}-{timestamp}.csv`。
|
||||
- 导出字段包含任务号、动作、原状态、新状态、操作人、TraceId、备注和时间,便于运营单独复盘验收、驳回、回收和协作变更记录。
|
||||
- 本增量复用任务详情已加载的事件流水数据,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `exportTaskEvents` 和任务流水导出文件名前缀。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 任务成员导出增量
|
||||
|
||||
- 后台任务“协作者管理”弹窗新增“导出成员”按钮,可将当前任务已加载成员分账导出为 `cleaning-task-members-{taskNo}-{timestamp}.csv`。
|
||||
- 导出字段包含任务号、成员 ID、昵称、角色、分账金额和结算时间,便于运营在协作分账调整、结算争议和现场复盘时单独留存成员口径。
|
||||
- 本增量复用协作者管理弹窗已加载的成员数据,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `exportTaskMembers`、任务成员导出文件名前缀和“导出成员”入口。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 结算详情导出增量
|
||||
|
||||
- 后台结算单详情弹窗新增“导出详情”按钮,可将单张结算单导出为 `cleaning-settlement-detail-{settlementNo}-{timestamp}.csv`。
|
||||
- 导出内容包含结算单摘要、状态、保洁员、门店、任务数、金额、转账状态、外部流水、微信确认参数、失败原因、备注、创建时间和任务明细。
|
||||
- 本增量复用结算详情接口已加载的数据,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `exportSettlementDetail` 和结算详情导出文件名前缀。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-06 运营待处理导出增量
|
||||
|
||||
- 后台保洁运营首屏“运营待处理”区新增“导出待处理”入口,可将当前已加载的待处理队列摘要导出为 `cleaning-operational-queue-{timestamp}.csv`。
|
||||
- 导出字段包含队列、数量、金额和对应入口,覆盖待验收任务、驳回补做、待确认结算、待发放金额、待结算金额和已完成任务。
|
||||
- 本增量复用统计接口已加载的首屏数据,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `operationalQueueTotal`、`exportOperationalQueue`、导出文件名前缀和“导出待处理”入口。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 保洁员待补资料导出增量
|
||||
|
||||
- 后台保洁员管理页新增“导出待补”按钮,可将当前已加载筛选页中资料不完整的保洁员导出为 `cleaning-cleaner-profile-issues-{timestamp}.csv`。
|
||||
- 导出字段包含保洁员 ID、姓名、状态、待补项目、微信绑定、门店范围、脱敏手机号、最近登录和备注,便于真实微信转账前集中补齐人员资料。
|
||||
- 待补项目按未绑定微信、未配置门店范围和缺姓名判定,复用前端已加载员工列表数据,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `profileIssues`、`exportCleanerProfileIssues`、导出文件名前缀和“导出待补”入口。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 联调风险清单导出增量
|
||||
|
||||
- 后台现场联调面板新增“风险”按钮,可将当前本地联调草稿中的阻断、待处理和缺证据检查项导出为 `cleaning-field-risk-register-{timestamp}.csv`。
|
||||
- 导出字段包含联调批次、现场/门店、负责人、执行时间、最后更新时间、检查项、状态、风险/缺口、应留证据、证据记录和下一步,便于真实商户、微信转账、硬件厂商和现场负责人拆分收口。
|
||||
- 风险清单复用浏览器本地联调草稿和既有 UTF-8 BOM CSV 下载工具,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `riskItems`、`exportRiskRegister`、导出文件名前缀和缺证据标记,避免后续联调面板改动丢失风险导出能力。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 联调归档包导出增量
|
||||
|
||||
- 后台现场联调面板新增“归档包”按钮,只有收口门禁满足时才允许导出 `cleaning-field-closeout-archive-{timestamp}.json`。
|
||||
- 归档包包含 schema、导出时间、场次信息、收口门禁、统计摘要、分类进度、风险项、待补证据项、全量检查项和交接报告文本,便于真实商户、微信转账和硬件现场收口后一次性归档。
|
||||
- 本增量复用本地联调草稿、风险清单、证据清单和交接报告构建逻辑,不新增后端路由、数据库字段或第三方依赖。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `exportCloseoutArchive`、`buildCloseoutArchive`、`serializeHandoffItem`、归档包 schema 和文件名前缀。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 联调负责人筛选增量
|
||||
|
||||
- 后台现场联调面板新增负责人筛选,支持查看全部负责人、未分派检查项或指定负责人名下检查项。
|
||||
- 筛选项从当前本地联调草稿中的负责人记录自动聚合,并与分类、状态筛选组合生效,便于真实商户、微信转账和硬件现场执行时按责任人拆分处理。
|
||||
- 重置联调草稿时负责人筛选同步回到“全部负责人”,避免重置后停留在空结果筛选状态。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `ownerFilter`、`ownerOptions`、负责人筛选控件和未分派选项。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 联调期限记录增量
|
||||
|
||||
- 后台现场联调面板为每个检查项新增“期限”字段,可记录负责人处理阻断、补证据或完成验收的目标日期。
|
||||
- 批量标记弹窗支持批量写入期限;留空时不覆盖已有期限,便于现场负责人按筛选结果统一分派和排期。
|
||||
- 阻断摘要、待补证据清单、交接报告、清单 CSV、风险清单、JSON 快照导入导出和归档包均同步带出期限字段。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `deadline`、期限列、批量标记期限和日期占位。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 联调逾期清单增量
|
||||
|
||||
- 后台现场联调面板新增逾期统计和“逾期检查项”清单,自动汇总已过期限且状态仍未通过/不适用的检查项。
|
||||
- 新增“复制逾期清单”按钮,可带出批次、现场、负责人、最后更新、状态、负责人、期限和结果记录,便于现场负责人追踪超时缺口。
|
||||
- 收口门禁会把逾期检查项纳入待补字段;交接报告、清单 CSV 和归档包同步带出逾期数量与逾期明细。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `overdueItems`、`isOverdueDraft`、`parseDeadline`、`copyOverdueSummary` 和逾期复制提示。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 联调负责人看板增量
|
||||
|
||||
- 后台现场联调面板新增“负责人看板”,按负责人自动聚合总项、通过、待处理、阻断、缺证据和逾期数量。
|
||||
- 负责人卡片可直接切换负责人筛选,便于现场负责人从全局看板快速聚焦自己名下或未分派的检查项。
|
||||
- 新增“复制负责人摘要”按钮;交接报告和归档包同步带出负责人维度汇总,便于真实商户、微信转账和硬件现场复盘。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `ownerProgress`、`copyOwnerSummary`、负责人看板文案和样式类。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 联调执行阶段看板增量
|
||||
|
||||
- 后台现场联调面板新增“执行阶段看板”,按商户、微信、转账和硬件阶段聚合通过数、证据数、阻断、缺证据、逾期和下一关注项。
|
||||
- 阶段卡片可直接切换分类筛选,便于现场负责人从阶段视图快速进入商户资料、微信域名、真实转账或硬件联动缺口。
|
||||
- 新增“复制阶段摘要”按钮;交接报告和归档包同步带出阶段维度汇总,便于真实商户、微信转账和硬件现场按阶段收口。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `stageProgress`、`copyStageSummary`、阶段看板文案和样式类。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 保洁员绩效排行增量
|
||||
|
||||
- 管理侧保洁统计接口的成员维度新增 `completedTaskCount` 和 `rejectedTaskCount`,真实返回每个保洁员的完成任务数和驳回任务数。
|
||||
- 后台统计页新增“保洁员绩效排行”,按任务数、完成数、驳回数和结算金额排序展示前 8 名,并可点击保洁员卡片进入该保洁员统计筛选。
|
||||
- 新增“导出排行”按钮,生成 `cleaning-cleaner-performance-{timestamp}.csv`;统计总导出同步带出成员完成、驳回和绩效排行区段。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查绩效排行 UI、导出文件名前缀、完成/驳回字段和后端统计 SQL 聚合。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS;`npm test -- --runInBand backend/tests/cleaning-route.test.mjs`(`backend/`,实际执行后端全量测试链)PASS。
|
||||
|
||||
## 2026-07-07 保洁员端统计看板增量
|
||||
|
||||
- 保洁员端 `GET /app-api/cleaning/stats` 新增任务总数、完成数、驳回数、完成率和已结算金额,继续按负责人/协作者口径统计当前保洁员可见任务。
|
||||
- 小程序保洁任务页新增统计看板,展示任务总数、完成率、驳回、待结算和已结算金额,便于保洁员本人核对任务处理和结算口径。
|
||||
- `scripts/check-miniapp-m08-a.mjs` 扩展检查 `settledRewardText`、`completionRate`、`stats-board` 和统计看板文案,避免后续小程序保洁统计能力回退。
|
||||
- 验证:`npm test -- --runInBand backend/tests/cleaning-route.test.mjs`(`backend/`,实际执行后端全量测试链)PASS;`node scripts/check-miniapp-m08-a.mjs` PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 2026-07-07 任务免清洁增量
|
||||
|
||||
- 后端新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,管理侧可将 WAITING、CLAIMED、STARTED、SUBMITTED、REJECTED 任务标记为 `EXEMPT`。
|
||||
- `CleaningTaskRepository.exempt` 在事务内锁定任务、清零任务奖励、移除未结算协作者并写入 `EXEMPT` 事件;待结算候选仍限定 `COMPLETED` 且未结算任务,免清洁不进入结算。
|
||||
- 后台任务面板新增“免清洁”单任务操作和 `EXEMPT` 状态筛选,操作后刷新任务列表和统计概览。
|
||||
- `backend/tests/cleaning-route.test.mjs` 覆盖免清洁路由透传,`scripts/check-admin-m08-b.mjs` 增加免清洁 API、父组件、任务面板和仓储静态检查。
|
||||
- 验证:`npm run build`(`backend/`)PASS;`npm test -- --runInBand backend/tests/cleaning-route.test.mjs`(`backend/`,实际执行后端全量测试链)PASS;`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS;`node scripts/check-miniapp-m08-a.mjs` PASS;`check-secrets.ps1` PASS;`check-large-files.ps1` 仅保留既有 `参考\mazongjian-server.xjar` 超 100MB 警告;`git diff --check` PASS。
|
||||
|
||||
## 2026-07-08 免清洁统计展示增量
|
||||
|
||||
- 管理侧 `GET /admin-api/cleaning/statistics` 新增 `exempted` 统计字段,summary、门店明细和每日趋势均可返回免清洁数量。
|
||||
- 后台统计页新增免清洁概览卡、每日趋势免清洁数量和门店明细免清洁列;统计 CSV 导出同步带出免清洁数量,并保留每日发放和门店奖励口径。
|
||||
- `backend/tests/cleaning-route.test.mjs` 补充 `summary.exempted`、`byStore[].exempted`、`trend[].exempted` 断言,`scripts/check-admin-m08-b.mjs` 增加免清洁统计展示和后端 SQL 聚合探针。
|
||||
- 验证:`npm run build`(`backend/`)PASS;`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS;`npm test -- --runInBand backend/tests/cleaning-route.test.mjs`(`backend/`,实际执行后端全量测试链)PASS;`node scripts/check-miniapp-m08-a.mjs` PASS;`check-status-docs.ps1` PASS;`check-secrets.ps1` PASS;`check-large-files.ps1` 仅保留既有 `参考\mazongjian-server.xjar` 超 100MB 警告;`git diff --check` PASS。
|
||||
|
||||
## 2026-07-08 批量免清洁操作增量
|
||||
|
||||
- 后台任务列表新增“批量免清洁”工具栏按钮,允许多选 WAITING、CLAIMED、STARTED、SUBMITTED、REJECTED 等可免清洁任务后统一标记。
|
||||
- 任务面板新增 `selectedExemptableTasks` 和 `selectedExemptableCount`,选择列从仅待验收扩展为可批量处理任务,同时批量验收/批量驳回仍只作用于 `SUBMITTED` 待验收任务。
|
||||
- 父级工作台新增 `handleExemptMany`,按任务 ID 顺序复用既有免清洁 API,完成后刷新任务列表和统计概览。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查批量免清洁按钮、可免清洁任务过滤和父子组件事件,避免后续管理端批量运营能力回退。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS;`node scripts/check-miniapp-m08-a.mjs` PASS;`check-status-docs.ps1` PASS;`check-secrets.ps1` PASS;`check-large-files.ps1` 仅保留既有 `参考\mazongjian-server.xjar` 超 100MB 警告;`git diff --check` PASS。
|
||||
|
||||
## 2026-07-08 联调检查项搜索增量
|
||||
|
||||
- 后台现场联调面板新增关键字搜索框,可按检查项 ID、类别、标题、期望、应留证据、状态、负责人、期限、结果记录和证据记录检索。
|
||||
- `filteredChecklist` 新增 `matchesKeyword` 条件,关键字检索与分类、状态、负责人筛选叠加生效;批量标记继续作用于当前筛选出的检查项,便于现场按流水号、DeviceID、负责人或证据编号快速定位后批量处理。
|
||||
- `admin/src/styles.css` 新增 `.handoff-search` 响应式宽度,桌面保持工具栏稳定,移动端占满一行。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `keywordFilter`、`matchesKeyword`、搜索框文案和样式,避免后续联调面板回退到只能按状态/负责人筛选。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS;`node scripts/check-miniapp-m08-a.mjs` PASS;`check-status-docs.ps1` PASS;`check-secrets.ps1` PASS;`check-large-files.ps1` 仅保留既有 `参考\mazongjian-server.xjar` 超 100MB 警告;`git diff --check` PASS。
|
||||
|
||||
## 2026-07-08 联调筛选导出增量
|
||||
|
||||
- 后台现场联调面板将“导出”改为“导出筛选”,CSV 明细只导出当前分类、状态、负责人和关键字筛选后的检查项。
|
||||
- CSV 顶部新增“当前筛选条件”和“导出检查项数”,带出分类、状态、负责人、关键字和导出数量,便于现场按某个负责人、阻断原因、DeviceID 或证据编号导出交接。
|
||||
- `exportChecklist` 新增 `exportItems = filteredChecklist.value` 和 `buildFilterSummary`,让导出结果与表格、批量标记保持同一筛选口径。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查“导出筛选”、`exportItems`、`buildFilterSummary` 和筛选元数据字段,避免后续 CSV 又回退为全量导出。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS;`node scripts/check-miniapp-m08-a.mjs` PASS;`check-status-docs.ps1` PASS;`check-secrets.ps1` PASS;`check-large-files.ps1` 仅保留既有 `参考\mazongjian-server.xjar` 超 100MB 警告;`git diff --check` PASS。
|
||||
|
||||
## 2026-07-15 联调筛选清除增量
|
||||
|
||||
- 后台现场联调面板新增“清除筛选”按钮;仅当分类、状态、负责人或关键字存在任一筛选条件时可用。
|
||||
- 清除操作将四项视图条件恢复为全量口径,不会修改已填写的检查项状态、负责人、期限、结果记录或证据记录。
|
||||
- `scripts/check-admin-m08-b.mjs` 扩展检查 `FilterX`、`hasActiveFilters`、`clearHandoffFilters` 和按钮文案,避免后续筛选导出能力缺少无损返回全量视图的入口。
|
||||
- 验证:`npm run build`(`admin/`)PASS;`node scripts/check-admin-m08-b.mjs` PASS。
|
||||
|
||||
## 状态
|
||||
|
||||
M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建保洁任务、统计、管理员任务查询、指派、验收、驳回、免清洁、批量验收、批量驳回、批量免清洁、超时回收、任务事件流水、任务流水导出、多人协作分账、任务成员导出、任务详情协作分账展示、任务详情导出、待结算候选与后台预览/导出、正式结算单生成、确认、结算详情导出、结算列表导出、任务列表导出、结算批量同步微信状态、结算转账状态筛选、微信确认参数展示与导出、微信转账预检资料展示与导出、真实微信转账前自动预检拦截、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、非生产 Mock 门禁、后台任务成员协作管理、任务详情照片预览、操作流水、结算单详情、统计明细、统计导出、保洁员绩效排行、保洁员端统计看板、任务免清洁标记、免清洁统计展示、保洁员资料管理界面、保洁员列表导出、保洁员待补资料导出、手机号维护、微信绑定状态、每日统计趋势、任务/结算/统计/候选门店和保洁员筛选、任务/协作者/结算/统计保洁员选择器、运营待处理快捷入口、运营待处理导出以及真实商户/微信/转账/硬件现场联调清单、联调检查项搜索、联调筛选结果导出与筛选清除、联调状态筛选、联调负责人筛选、联调负责人看板、联调执行阶段看板、联调期限记录、联调逾期清单、联调批量标记、联调阻断摘要复制、联调场次信息记录、联调 JSON 快照导入导出、联调分类进度、联调验收结论复制、联调更新时间、联调证据记录、待补证据清单、联调交接报告、联调风险清单导出、联调归档包导出、联调收口签署字段和联调收口门禁;真实商户现场执行和更完整的后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。
|
||||
|
||||
## 提交
|
||||
|
||||
- 最近工程提交:`96c5fa4` / `M08-B联调筛选清除增量`
|
||||
- 上次远端校验:`3bee3c47b6935f382009a6da5c325f03414a9b03`。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+13
-5
@@ -17,11 +17,15 @@
|
||||
| OPS-003 | 整仓发布清单 | M00-E/M10 | PARTIAL | `59d92c0` | `deploy-business.sh --dry-run` 可在 Windows/WSL 验证当前 HEAD 的 release manifest 结构;`check-release-manifest.ps1` 已接入 `test-all.ps1`;正式构建待项目生成。 | 后端/后台尚未生成,生产菜单未执行。 | M01/M09 后接入真实构建结果,并在生产菜单 2 生成真实 release manifest。 |
|
||||
| OPS-004 | 菜单式更新与环境监测 | M00-E/M10 | PARTIAL | `292ab7f` | `setup.sh` 已接入初始化、更新、MQTT、HTTPS、状态、备份、恢复、回滚和诊断菜单;`--backup-status` 已在 WSL 验证,可检查备份工具、目录和模板。 | 未在生产 Ubuntu 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 |
|
||||
| IOT-001 | MQTT Broker 生产部署 | M00/M06 | PARTIAL | `d01f771` | 菜单 3 已支持 Ubuntu 24.04 amd64 原生安装/更新与复检;正式 Topic ACL 已固定;后端实现 MQTT.js 3.1、QoS 1、持久会话、自动重连、订阅恢复、健康检查、消息上限和真实硬件烟测运行器;Windows 全量测试通过,WSL EMQX 5.8.9 服务级检查通过。 | 缺生产 EMQX 安装、正式账号、ACL/端口/备份验收;WSL 本地账号未配置,真实硬件烟测按预期 SKIP。 | 由管理员在生产菜单 3 执行并登记结果;提供生产账号、DeviceID 和实物后执行 `node dist/devices/hardware-smoke-runner.js`。 |
|
||||
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `e24920c` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;`scripts/check-miniapp-m08-a.mjs`、相关会员/权益测试通过。 | 多角色菜单、充值微信支付调起、权益下单抵扣深度集成和真机联调尚未完成。 | 继续 M08-A,补充值微信支付调起和权益下单抵扣集成。 |
|
||||
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `96c5fa4` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做入口、统计和管理侧验收/驳回/免清洁/批量验收/批量驳回/批量免清洁/指派/结算/多人协作/发放 API;后端已接入微信商家转账适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、结算详情、管理统计、员工角色筛选、统计趋势、统计按保洁员筛选、待结算候选筛选、任务事件流水、保洁员微信绑定状态、结算转账状态筛选和非生产 Mock 门禁;后台已建立保洁运营工作台,可维护任务协作者、导出单任务成员分账、查看任务详情、照片预览、操作流水和协作分账、批量验收/批量驳回待验收任务、单条或批量标记任务免清洁、导出当前任务列表、单任务详情和单任务操作流水、查看并导出结算单详情、预览并导出待结算候选任务、导出结算列表、批量同步微信转账状态、按转账状态筛选结算单、在结算详情和导出中查看微信确认参数、在微信转账预检中查看并导出凭据/授权/通知 URL/平台证书/OpenID 资料和检查项、发起真实转账前自动预检并在阻断项未处理时禁用 API 发起、统计明细、每日趋势、保洁员绩效排行、保洁员端统计看板、导出当前统计结果、管理保洁员手机号/资料完整度、导出当前保洁员列表、导出当前待补资料保洁员清单、通过保洁员选择器完成任务指派/协作者/结算生成人员选择,按门店和保洁员筛选任务/结算/统计/候选,通过首屏运营待处理入口跳转待验收、驳回补做、待确认结算和待发放队列,并可导出待处理队列摘要,查看微信转账真实商户前置条件,提供真实商户/微信/转账/硬件现场联调清单并支持本地记录、场次信息、关键字搜索、筛选结果 CSV 导出、状态筛选、联调负责人筛选、联调负责人看板、联调执行阶段看板、联调期限记录、联调逾期清单、批量标记、阻断摘要复制、JSON 快照导入导出、分类进度展示、验收结论复制、最后更新时间、证据记录、待补证据清单、联调交接报告、联调风险清单导出、联调归档包导出、收口签署字段和收口归档门禁;`admin npm run build` 和 `node scripts/check-admin-m08-b.mjs` 通过。 | M08-A 真机联调缺微信后台/商户/硬件条件;M08-B 真实商户现场执行和更完整管理界面待继续。 | 继续 M08-B,补更完整保洁运营管理界面和真实商户现场执行记录。 |
|
||||
| CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 |
|
||||
| BKG-010 | 后台多小程序管理 | M02-A/M08/M10 | PARTIAL | `8b8fb28` | 已建立逻辑应用、租户绑定和租户配置模型;多租户 AppID 未指定租户会被拒绝。 | 尚无后台管理页面,多 AppID 真机配置未验证。 | M02-B/C 完成身份权限,M08 增加管理页面。 |
|
||||
| AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 |
|
||||
| AUTH-002 | 多级权限 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已建立六类角色、权限目录、用户角色和门店范围;后台员工 API 同时执行 capability、租户和门店范围校验,越权分配被拒绝并记录审计。 | 后续业务资源接口和前端按钮/菜单仍需逐模块接入。 | M03 起所有管理接口复用 capability 与数据范围;M08 完成前端菜单按钮控制。 |
|
||||
| CLN-001 | 保洁人员管理 | M08-B/M09 | PARTIAL | `M08-B保洁员待补资料导出增量` | `CLEANER` 角色已接入 `cleaning.task.read/write` 和 `cleaning.statistics.read`,保洁接口按登录态、capability 和门店范围裁剪任务大厅;后台已可按 `CLEANER` 角色筛选员工,创建保洁员,编辑姓名/手机号/门店范围/备注,启停账号并重置会话;员工列表返回头像 URL 和微信小程序 openid 绑定状态,后台展示“已绑微信/未绑微信”和资料完整度标签,并复用该列表作为任务指派、协作者管理和生成结算单的可搜索保洁员选择源;保洁员管理页可导出当前筛选页 CSV,带出资料完整度、微信绑定、门店范围、脱敏手机号、最近登录/IP 和备注,也可单独导出待补资料清单,列出未绑定微信、未配置门店范围和缺姓名等缺口。 | 真实商户现场联调和更完整管理界面仍待继续。 | 继续 M08-B/M09,补更完整保洁运营管理界面。 |
|
||||
| CLN-002 | 保洁任务大厅 | M08-B/M09 | PARTIAL | `96c5fa4` | 新增 `qipai_cleaning_tasks`、`qipai_cleaning_task_events`、`GET /app-api/cleaning/tasks/hall`、`GET /app-api/cleaning/tasks/mine`、接单、开始、照片上传、提交验收、驳回补做接口;订单 `FINISHED` 时同事务幂等创建 `WAITING` 保洁任务;管理侧已支持任务查询、按状态/门店/负责人或协作者筛选、任务详情、照片预览、任务事件流水、任务详情协作分账、指派、验收、驳回、免清洁、批量验收、批量驳回、批量免清洁、超时回收、协作者查看/添加/移除、待结算候选筛选与后台预览/导出、正式结算单生成/确认、发放成功标记、发放失败记录、微信转账发放、转账预检、主动同步、批量同步、微信通知回调、结算单详情、结算详情导出、结算转账状态筛选、微信确认参数展示与导出、管理统计、每日趋势、统计按保洁员筛选和保洁员资料管理;小程序 `pages/cleaner/tasks` 已调用真实 app-api 并支持相机/相册上传;后台 `admin/` 已提供任务筛选、当前任务列表导出、单任务详情导出、单任务流水导出、单任务成员导出、待验收任务多选批量验收/驳回、可免清洁任务多选批量标记、保洁员选择器指派、验收、驳回、免清洁、超时回收、任务成员协作管理、任务详情操作流水和协作分账、结算详情、结算详情导出、待结算候选预览导出、结算列表导出、结算批量同步微信状态、转账状态筛选、微信确认参数展示与导出、统计明细、每日趋势、保洁员绩效排行、保洁员端统计看板、统计保洁员筛选、保洁员资料、待补资料导出、手机号维护、微信绑定状态、结算微信转账预检资料展示导出和转账前自动拦截界面、运营待处理快捷入口、运营待处理导出、真实商户/微信/转账/硬件现场联调清单、联调检查项搜索、联调筛选结果导出与筛选清除、联调状态筛选、联调负责人筛选、联调负责人看板、联调执行阶段看板、联调期限记录、联调逾期清单、联调批量标记、联调阻断摘要复制、联调场次信息记录、联调 JSON 快照导入导出、联调分类进度、联调验收结论复制、联调更新时间、联调证据记录、待补证据清单、联调交接报告、联调风险清单导出、联调归档包导出、收口签署字段和收口归档门禁。 | 真实商户现场执行和更完整管理界面仍待继续。 | 继续 M08-B,补更完整保洁运营管理界面。 |
|
||||
| CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `642659c` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员负责人/协作者任务状态计数、任务总数、完成数、驳回数、完成率、待结算金额和已结算金额,小程序展示已抢、进行、待验、完成、任务总数、完成率、驳回、待结算和已结算金额;管理侧新增 `GET /admin-api/cleaning/statistics`,按租户、门店范围、日期、门店和保洁员筛选返回状态分布、门店明细、免清洁统计、结算口径、成员分账排行和每日趋势,保洁员筛选同时覆盖负责人/协作者任务口径、结算单口径和每日已发放金额;后台首屏汇总改为使用管理侧统计接口,统计页支持今日、近7天、本月快捷周期、每日趋势条形展示、免清洁汇总/门店/趋势展示和可搜索保洁员筛选,任务/结算/统计列表支持门店和保洁员筛选以辅助定位统计异常,并可导出当前筛选条件下的汇总、任务状态、结算口径、每日趋势、门店明细、免清洁数量和成员分账 CSV。 | 真实商户现场联调和更完整管理界面待继续。 | M08-B/M09-C 补更完整管理界面。 |
|
||||
| CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 |
|
||||
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;WSL MySQL 实测权限变化撤销活动会话。 | 后台与管理员小程序页面尚未实现。 | M08 接入管理界面。 |
|
||||
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现分页筛选 API、注册/登录时间、状态、角色、门店范围以及手机号/IP 脱敏。 | Vue3 后台页面尚未实现。 | M08 实现响应式用户管理页面。 |
|
||||
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
|
||||
@@ -32,17 +36,17 @@
|
||||
| MAP-001 | 地图选店和最近门店 | M03-C/M08 | PARTIAL | `4ac2c96` | 已实现 AppID/tenant 隔离门店发现、城市/营业筛选、按门店时区判断营业中、后端 Haversine 距离排序;小程序支持定位和手工城市降级,并可从门店卡片进入公开门店详情与房间列表。 | 地图 SDK 可视化和真机定位授权尚未验收。 | M08-A 继续补真机定位、地图展示和门店装修渲染。 |
|
||||
| QR-001 | 门店/房间场景码与 NFC | M03-D/M08 | PARTIAL | `d563078` | 已实现场景码生成、重建失效、撤销、扫码统计和二维码/NFC 统一页面解析;返回权限固定为空,旧码失效已在 MySQL 8.4.9 实测。 | 真实微信小程序码图片生成依赖可用 AppSecret 与真机环境。 | M08 接入微信小程序码接口并完成真机扫描验收。 |
|
||||
| WIFI-001 | 受控 Wi-Fi 凭据 | M03-D/M04/M08 | PARTIAL | `4ac2c96` | 正式订单与分享令牌均执行最小权限;M08-A 门店详情新增登录后受控 Wi-Fi 请求入口,不在未鉴权页面暴露密码。 | 真实到店权限、真机 Wi-Fi 连接和设备权限仍需现场联调。 | M08-A 补有效订单下的 Wi-Fi 真机连接验证。 |
|
||||
| ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `e24920c` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、订单生产微信预支付调起、订单列表、订单详情、状态历史、取消测算、取消订单、续费和同门店换房入口。 | 缺真实微信和团购平台授权联调;充值微信支付调起、余额/套餐支付和下单权益抵扣入口待补。 | M08-A 继续补充值微信支付调起和权益支付。 |
|
||||
| WAL-001 | 充值优惠 | M07-B/M08 | PARTIAL | `dbdfe3d` | 已建立充值规则和充值订单表,支持有效期、适用门店、限购、启停、clientRequestId 幂等;M08-A 新增顾客端充值计划查询、充值单创建 API 和小程序充值页;支付成功后调用钱包入账,重复支付回调不重复充值;Windows 全量后端回归通过。 | 真实微信充值支付调起和回调仍需 M05 生产凭据与后续联调。 | M08-A 继续接入充值微信支付调起。 |
|
||||
| ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `4701674` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、余额支付、订单生产微信预支付调起、优惠券/套餐下单抵扣和可视化选择器、订单列表、订单详情、状态历史、取消测算、取消订单、续费和同门店换房入口。 | 缺真实微信和团购平台授权联调;真机支付验收待补。 | M08-A 继续补真机合法域名和真实微信支付验收。 |
|
||||
| WAL-001 | 充值优惠 | M07-B/M08 | PARTIAL | `53f5d67` | 已建立充值规则和充值订单表,支持有效期、适用门店、限购、启停、clientRequestId 幂等;M08-A 新增顾客端充值计划查询、充值单创建 API、小程序充值页、微信 JSAPI 预支付调起和 WeChat Pay v3 通知验签解密入账;重复支付回调不重复充值;Windows 全量回归通过。 | 真实微信商户凭据、生产合法域名和微信侧回调仍需联调。 | M08-A 继续补余额/优惠券/套餐下单抵扣集成。 |
|
||||
| WAL-002 | 门店独立会员余额 | M07-A | DONE | `63711ad` | 已建立租户/门店余额范围策略、现金/赠送双余额账户和不可变流水;充值、赠送、消费、退款、人工调整均预留 entry type;消费按赠送后现金扣款,业务号幂等,余额不足拒绝;Windows 全量后端回归通过。 | - | M07-D 聚合会员余额和权益状态。 |
|
||||
| WAL-003 | 余额账单 | M07-A/M08 | PARTIAL | `7446852` | 账单底表已包含业务类型、业务号、现金/赠送变动、变动后余额、操作人、trace 和 metadata;M07-B 充值成功后写入真实充值流水;M08-A 个人中心已展示最近钱包账单。 | 完整分页账单页面待继续;优惠券/套餐不直接写钱包流水。 | M08-A 增加分页账单或权益明细入口。 |
|
||||
| WAL-004 | 优惠券和套餐权益 | M07-C/M08 | PARTIAL | `e24920c` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;M08-A 个人中心已展示可用/冻结券和套餐汇总,并新增本人优惠券/套餐明细页。 | 发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M08-A 继续接入下单使用权益入口。 |
|
||||
| WAL-004 | 优惠券和套餐权益 | M07-C/M08 | PARTIAL | `4701674` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;M08-A 个人中心已展示可用/冻结券和套餐汇总,并新增本人优惠券/套餐明细页;下单预占可通过可视化选择器冻结优惠券/套餐并重算订单金额,时长券可按服务端单价折算抵扣,支付成功确认核销,取消订单释放冻结权益。 | 发放/购买入口和真机支付验收待补。 | M09 接入后台发放/购买和管理入口;M08-A 继续真机验收。 |
|
||||
| MEM-001 | 会员管理 | M07-D/M08 | PARTIAL | `7446852` | 已实现后台会员聚合查询服务和 `/admin-api/members`、`/admin-api/members/:id`;新增顾客端 `/app-api/profile` 和小程序个人中心,可查看注册/最近登录、订单数、消费额、余额、充值、优惠券、套餐和最近钱包流水,手机号脱敏,租户/门店范围及本人读取通过测试。 | 管理员赠券、余额人工调整、禁用、备注和后台前端页面需在 M09/M08 管理端接入并复用既有审计能力。 | M08-A 继续补顾客端权益明细;M09 接入后台会员管理页面。 |
|
||||
| GRP-001 | 团购券兑换 | M05-C | PARTIAL | `cda640b` | 已实现美团/点评、抖音、快手统一适配器,支持顾客粘贴/扫码值、管理员人工核销、Mock/API 核销、clientRequestId 幂等和券码 SHA-256 存储;重复券不能再次记账,WSL MySQL 8.4.9 实测通过。 | 缺各平台商家 API 授权,未执行真实厂商核销。 | 取得授权后配置 API endpoint/token 并执行真实验券。 |
|
||||
| GRP-002 | 美团直订 | M05-C | PARTIAL | `cda640b` | 已实现 HMAC 回调、事件幂等、外部门店/房间映射、未映射人工队列、顾客认领和团购支付订单生成;映射成功订单可进入 PAID。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 |
|
||||
| GRP-003 | 管理员验券 | M05-C/M08 | PARTIAL | `cda640b` | 已提供后台人工验券、配置、映射和记录查询 API,门店数据范围校验生效,券码列表只返回脱敏值。 | 管理员小程序扫码界面待 M08,真实平台核销待授权。 | M08 接入扫码界面;授权后切换 API 模式。 |
|
||||
| FRN-001 | 加盟商与自动分账 | M05-D/M10/M08 | PARTIAL | `1680d73` | 已实现租户/门店收款账户、商户号、环境凭据引用、分账授权状态、接收方哈希脱敏、比例总和校验、支付后幂等分账和明细查询;未授权不能开启,生产环境 Mock 强制关闭。 | 缺微信支付服务商和分账权限,尚未执行真实分账。 | M10/M08 补加盟流程与界面;取得权限后执行真实分账与对账。 |
|
||||
| BKG-007 | 后台微信支付配置 | M05-D/M08 | PARTIAL | `1680d73` | 已提供收款账户、分账接收方、门店/全局比例策略和授权状态 API;私钥、APIv3 Key 和接收方真实标识只使用环境引用,查询仅返回脱敏信息。 | Vue 后台页面待 M08;真实商户与服务商授权未提供。 | M08 接入响应式配置页面;授权后完成生产验收。 |
|
||||
| BKG-007 | 后台微信支付配置 | M05-D/M08 | PARTIAL | `M08-B微信转账预检拦截增量` | 已提供收款账户、分账接收方、门店/全局比例策略和授权状态 API;私钥、APIv3 Key 和接收方真实标识只使用环境引用;M08-B 新增保洁结算微信转账预检,后台可查看并导出脱敏商户号、凭据引用、授权状态、账户范围、场景 ID、报备项数量、通知地址、平台证书数量、保洁员 openid 和检查项是否满足真实转账前置条件;真实 API 转账弹窗会自动执行预检,存在 FAIL 阻断项时禁用发起。 | 真实商户与服务商授权、微信后台场景报备和生产小额转账仍需现场联调。 | M08/M09 接入更完整支付配置页面;授权后完成生产验收。 |
|
||||
| DEV-003 | 设备管理 | M06-B/M08 | PARTIAL | `9144fa8` | 已实现 DeviceID/IMEI/ICCID/型号/固件/信号/能力资产,控制箱通道、智慧插座负载、Sub-1G 父子拓扑、状态快照、开放告警和维护记录查询;插座过载、过温、过流、过压/欠压、功率和温度阈值告警已接入;门店数据范围生效。 | 尚无后台/手机页面,真实硬件在线/计量仍需 M06-G 实物更新。 | M06-F 接入订单自动联动;M08 实现响应式界面。 |
|
||||
| BKG-004 | 后台设备管理 | M06-B/M08 | PARTIAL | `9144fa8` | 已提供设备入库、列表、拓扑、通道绑定、Sub-1G 绑定、状态、维护和插座控制 API;同一房间控制目标唯一,控制箱与智慧插座重复负载被数据库拒绝。 | Vue 后台页面和真实设备扫码入库待 M08/M06-G。 | M06-F 接入订单自动联动;M08 实现管理页面。 |
|
||||
| IOT-006 | MQTT 消息幂等 | M01/M06-C | DONE | `e795cdb` | 上行按租户、设备、Topic 和 payload SHA-256 唯一;QoS 1 重复消息只增加 `receive_count`,不重复更新命令或设备副作用;WSL MySQL 8.4.9 实测两次 ACK 仅一条事件。 | - | M06-D/E 继续复用同一消费链路。 |
|
||||
@@ -54,3 +58,7 @@
|
||||
| IOT-005 | 智慧插座接入 | M06-E | PARTIAL | `9144fa8` | `basicInfo/workInfo` 查询、`on/off`、`localtask/clearTask` 已通过适配器、命令持久化和后台 API 接入;本地任务限制 20 条;过载、过温、过流、过压/欠压、功率和温度阈值写入设备告警;Windows 全量后端回归通过。 | 缺 4G 智慧插座实物、DeviceID、生产 MQTT 账号/ACL 和现场负载,未执行真实计量与保护断电联调。 | M06-F 接入订单自动联动;M06-G 完成实物计量、开关、本地任务和保护事件矩阵。 |
|
||||
| IOT-008 | 设备告警与离线 | M06/M10 | PARTIAL | `d01f771` | `/devicewill/{DeviceID}` 可更新离线状态;低电量、timeout/full/unconfirm、插座过载、过温、过流、过压/欠压、功率和温度阈值可写入开放告警;订单联动任务失败会进入异步任务重试/补偿;真实硬件烟测运行器可等待 ACK 并输出 JSON 报告。 | 缺真实硬件离线、保护断电、任务结束和弱信号事件矩阵。 | 取得实物和生产 MQTT 账号后执行硬件烟测矩阵;当前旁路进入 M07-A。 |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
```yaml
|
||||
execution_cursor:
|
||||
current_module: M08
|
||||
current_stage: M08-A
|
||||
current_stage: M08-B
|
||||
stage_status: PARTIAL
|
||||
last_completed_stage: M07-D
|
||||
next_stage: M08-A
|
||||
last_engineering_commit: `e24920c`
|
||||
next_stage: M08-B
|
||||
last_engineering_commit: `96c5fa4`
|
||||
last_push_verified: true
|
||||
base_branch: main
|
||||
blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。"
|
||||
updated_at: 2026-06-25T12:30:00+08:00
|
||||
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 已完成照片上传、驳回补做、订单结束自动建保洁任务、管理员查询/指派/验收/驳回/免清洁、批量验收、批量驳回、批量免清洁、超时回收、任务事件流水、任务流水导出、任务成员导出、待结算候选与后台预览/导出、正式结算单生成/确认、多保洁员协作分账策略、任务详情协作分账展示、任务列表导出、任务详情导出、结算详情导出、结算列表导出、结算批量同步微信状态、结算转账状态筛选、微信确认参数展示与导出、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、微信转账预检资料展示与导出、真实微信转账前自动预检拦截、非生产 Mock 门禁、后台任务成员协作管理、任务详情照片预览、操作流水、结算单详情、统计明细、统计导出、每日统计趋势、保洁员绩效排行、保洁员端统计看板、任务免清洁标记、免清洁统计展示、保洁员资料管理界面、保洁员列表导出、保洁员待补资料导出、手机号维护、微信绑定状态展示、任务/协作者/结算/统计保洁员选择器、任务/结算/统计/候选门店和保洁员筛选、运营待处理快捷入口、运营待处理导出以及真实商户/微信/转账/硬件现场联调清单、联调检查项搜索、联调筛选结果导出与筛选清除、联调状态筛选、联调负责人筛选、联调负责人看板、联调执行阶段看板、联调期限记录、联调逾期清单、联调批量标记、联调阻断摘要复制、联调场次信息记录、联调 JSON 快照导入导出、联调分类进度、联调验收结论复制、联调更新时间、联调证据记录、待补证据清单、联调交接报告、联调风险清单导出、联调归档包导出、联调收口签署字段和联调收口门禁,仍需继续补真实商户现场执行和更完整的管理界面。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
|
||||
updated_at: 2026-07-15T00:00:00+08:00
|
||||
```
|
||||
|
||||
| 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 |
|
||||
@@ -24,7 +24,7 @@ execution_cursor:
|
||||
| M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 |
|
||||
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `d01f771` | docs/devlogs/2026-06-24-M06-G-真实硬件联调.md | M06-A 至 F 已完成 MQTT、拓扑、协议幂等、控制箱、Sub-1G 门锁、智慧插座和订单自动联动任务;M06-G 已补真实硬件烟测运行器,缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
|
||||
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
|
||||
| M08 微信原生小程序完整业务 | PARTIAL | `e24920c` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;充值微信支付调起、权益下单抵扣深度集成和真机联调继续留在 M08-A。 |
|
||||
| M08 微信原生小程序完整业务 | PARTIAL | `96c5fa4` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回、免清洁、批量验收、批量驳回、批量免清洁、超时回收、任务事件流水、任务流水导出、任务成员导出、任务详情协作分账展示、待结算候选与后台预览/导出、正式结算单生成/确认、多保洁员协作分账策略、任务列表导出、任务详情导出、结算详情导出、结算列表导出、结算批量同步微信状态、结算转账状态筛选、微信确认参数展示与导出、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、预检资料展示导出和真实转账前预检拦截、非生产 Mock 门禁、后台任务成员协作管理、任务详情照片预览、操作流水、结算单详情、统计明细、统计导出、保洁员绩效排行、保洁员端统计看板、任务免清洁标记、免清洁统计展示、保洁员资料管理界面、保洁员列表导出、保洁员待补资料导出、手机号维护、微信绑定状态、每日统计趋势、任务/协作者/结算/统计保洁员选择器、任务/结算/统计/候选门店和保洁员筛选、运营待处理快捷入口、运营待处理导出以及真实商户/微信/转账/硬件现场联调清单、联调检查项搜索、联调筛选结果导出与筛选清除、联调状态筛选、联调负责人筛选、联调负责人看板、联调执行阶段看板、联调期限记录、联调逾期清单、联调批量标记、联调阻断摘要复制、联调场次信息记录、联调 JSON 快照导入导出、联调分类进度、联调验收结论复制、联调更新时间、联调证据记录、待补证据清单、联调交接报告、联调风险清单导出、联调归档包导出、联调收口签署字段和联调收口门禁;真实商户现场执行和更完整的管理界面待继续。 |
|
||||
| M09 后台管理端完整业务 | TODO | - | - | - |
|
||||
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"pages/profile/index",
|
||||
"pages/benefits/index",
|
||||
"pages/recharge/index",
|
||||
"pages/cleaner/tasks",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
"window": {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
const { request, uploadImage, ensureLogin, cents } = require('../../utils/api.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
activeTab: 'hall',
|
||||
hallTasks: [],
|
||||
myTasks: [],
|
||||
stats: {
|
||||
byStatus: {},
|
||||
taskTotal: 0,
|
||||
completed: 0,
|
||||
rejected: 0,
|
||||
completionRate: 0,
|
||||
pendingSettlementText: cents(0),
|
||||
settledRewardText: cents(0),
|
||||
},
|
||||
selectedPhotosByTask: {},
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.refreshAll()
|
||||
},
|
||||
|
||||
async refreshAll() {
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
await ensureLogin()
|
||||
const [hall, mine, stats] = await Promise.all([
|
||||
request('/cleaning/tasks/hall?page=1&pageSize=20'),
|
||||
request('/cleaning/tasks/mine?page=1&pageSize=20'),
|
||||
request('/cleaning/stats'),
|
||||
])
|
||||
this.setData({
|
||||
hallTasks: (hall.data.items || []).map(formatTask),
|
||||
myTasks: (mine.data.items || []).map(formatTask),
|
||||
stats: formatStats(stats.data),
|
||||
})
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '保洁任务加载失败' })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
switchTab(event) {
|
||||
const tab = event.currentTarget.dataset.tab
|
||||
if (!tab || tab === this.data.activeTab) return
|
||||
this.setData({ activeTab: tab })
|
||||
},
|
||||
|
||||
async claimTask(event) {
|
||||
await this.mutateTask(event.currentTarget.dataset.taskId, 'claim')
|
||||
},
|
||||
|
||||
async startTask(event) {
|
||||
await this.mutateTask(event.currentTarget.dataset.taskId, 'start')
|
||||
},
|
||||
|
||||
async choosePhotos(event) {
|
||||
const taskId = event.currentTarget.dataset.taskId
|
||||
if (!taskId) return
|
||||
try {
|
||||
const result = await wxChooseMedia(9)
|
||||
const current = this.data.selectedPhotosByTask[taskId] || []
|
||||
const next = current.concat(result.tempFiles.map((item) => item.tempFilePath)).slice(0, 9)
|
||||
this.setData({ [`selectedPhotosByTask.${taskId}`]: next })
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '选择照片失败' })
|
||||
}
|
||||
},
|
||||
|
||||
async submitTask(event) {
|
||||
const taskId = event.currentTarget.dataset.taskId
|
||||
const localPhotos = this.data.selectedPhotosByTask[taskId] || []
|
||||
if (localPhotos.length === 0) {
|
||||
this.setData({ errorMessage: '请先上传至少一张保洁照片' })
|
||||
return
|
||||
}
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
let photoUrls = []
|
||||
try {
|
||||
await ensureLogin()
|
||||
const uploaded = await Promise.all(localPhotos.map((filePath) =>
|
||||
uploadImage(`/cleaning/tasks/${encodeURIComponent(taskId)}/photos`, filePath)
|
||||
))
|
||||
photoUrls = uploaded.map((item) => item.data.publicUrl).filter(Boolean)
|
||||
} catch (error) {
|
||||
this.setData({ loading: false, errorMessage: error.message || '照片上传失败' })
|
||||
return
|
||||
}
|
||||
await this.mutateTask(event.currentTarget.dataset.taskId, 'submit', { photoUrls })
|
||||
this.setData({ [`selectedPhotosByTask.${taskId}`]: [] })
|
||||
},
|
||||
|
||||
async reworkTask(event) {
|
||||
await this.mutateTask(event.currentTarget.dataset.taskId, 'rework')
|
||||
},
|
||||
|
||||
async mutateTask(taskId, action, data = {}) {
|
||||
if (!taskId) return
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
await ensureLogin()
|
||||
await request(`/cleaning/tasks/${encodeURIComponent(taskId)}/${action}`, {
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
await this.refreshAll()
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '保洁任务处理失败' })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function formatTask(task) {
|
||||
return {
|
||||
...task,
|
||||
rewardText: cents(task.rewardCents),
|
||||
timeText: formatDate(task.updatedAt || task.createdAt),
|
||||
canClaim: task.status === 'WAITING',
|
||||
canStart: task.status === 'CLAIMED',
|
||||
canSubmit: task.status === 'STARTED',
|
||||
canRework: task.status === 'REJECTED',
|
||||
canUpload: task.status === 'STARTED' || task.status === 'REJECTED',
|
||||
}
|
||||
}
|
||||
|
||||
function formatStats(stats) {
|
||||
const taskTotal = Number(stats.taskTotal || 0)
|
||||
const completed = Number(stats.completed || 0)
|
||||
const rejected = Number(stats.rejected || 0)
|
||||
return {
|
||||
byStatus: stats.byStatus || {},
|
||||
taskTotal,
|
||||
completed,
|
||||
rejected,
|
||||
completionRate: Number(stats.completionRate || 0),
|
||||
pendingSettlementText: cents(stats.pendingSettlementCents),
|
||||
settledRewardText: cents(stats.settledRewardCents),
|
||||
}
|
||||
}
|
||||
|
||||
function wxChooseMedia(count) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.chooseMedia({
|
||||
count,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
sizeType: ['compressed'],
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
const pad = (input) => String(input).padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "保洁任务"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<view>
|
||||
<view class="title">保洁任务</view>
|
||||
<view class="muted">待结算 {{stats.pendingSettlementText}} · 已结算 {{stats.settledRewardText}}</view>
|
||||
</view>
|
||||
<button size="mini" loading="{{loading}}" bindtap="refreshAll">刷新</button>
|
||||
</view>
|
||||
|
||||
<view class="tabs">
|
||||
<button class="{{activeTab === 'hall' ? 'tab active' : 'tab'}}" data-tab="hall" bindtap="switchTab">大厅</button>
|
||||
<button class="{{activeTab === 'mine' ? 'tab active' : 'tab'}}" data-tab="mine" bindtap="switchTab">我的</button>
|
||||
</view>
|
||||
|
||||
<view class="stats">
|
||||
<text>已抢 {{stats.byStatus.CLAIMED || 0}}</text>
|
||||
<text>进行 {{stats.byStatus.STARTED || 0}}</text>
|
||||
<text>待验 {{stats.byStatus.SUBMITTED || 0}}</text>
|
||||
<text>完成 {{stats.byStatus.COMPLETED || 0}}</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-board">
|
||||
<view class="stats-card">
|
||||
<text>任务总数</text>
|
||||
<strong>{{stats.taskTotal}}</strong>
|
||||
</view>
|
||||
<view class="stats-card">
|
||||
<text>完成率</text>
|
||||
<strong>{{stats.completionRate}}%</strong>
|
||||
</view>
|
||||
<view class="stats-card">
|
||||
<text>驳回</text>
|
||||
<strong>{{stats.rejected}}</strong>
|
||||
</view>
|
||||
<view class="stats-card">
|
||||
<text>已结算</text>
|
||||
<strong>{{stats.settledRewardText}}</strong>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||
|
||||
<block wx:if="{{activeTab === 'hall'}}">
|
||||
<view wx:if="{{!loading && hallTasks.length === 0}}" class="empty">暂无可接任务</view>
|
||||
<view wx:for="{{hallTasks}}" wx:key="id" class="task-card">
|
||||
<view class="task-title">{{item.storeName}} · {{item.roomName}}</view>
|
||||
<view class="muted">任务号 {{item.taskNo}}</view>
|
||||
<view class="muted">{{item.requirement || '常规保洁'}}</view>
|
||||
<view class="row">
|
||||
<text class="status">{{item.status}}</text>
|
||||
<text class="amount">{{item.rewardText}}</text>
|
||||
</view>
|
||||
<button wx:if="{{item.canClaim}}" data-task-id="{{item.id}}" bindtap="claimTask">接单</button>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:if="{{activeTab === 'mine'}}">
|
||||
<view wx:if="{{!loading && myTasks.length === 0}}" class="empty">暂无我的任务</view>
|
||||
<view wx:for="{{myTasks}}" wx:key="id" class="task-card">
|
||||
<view class="task-title">{{item.storeName}} · {{item.roomName}}</view>
|
||||
<view class="muted">任务号 {{item.taskNo}}</view>
|
||||
<view class="muted">{{item.timeText}}</view>
|
||||
<view class="row">
|
||||
<text class="status">{{item.status}}</text>
|
||||
<text class="amount">{{item.rewardText}}</text>
|
||||
</view>
|
||||
<view wx:if="{{item.rejectReason}}" class="warning">驳回:{{item.rejectReason}}</view>
|
||||
<button wx:if="{{item.canStart}}" data-task-id="{{item.id}}" bindtap="startTask">开始</button>
|
||||
<button wx:if="{{item.canRework}}" data-task-id="{{item.id}}" bindtap="reworkTask">重做</button>
|
||||
<view wx:if="{{item.canUpload}}" class="submit-box">
|
||||
<button data-task-id="{{item.id}}" bindtap="choosePhotos">选择照片</button>
|
||||
<view class="muted">至少 1 张,最多 9 张</view>
|
||||
<button data-task-id="{{item.id}}" bindtap="submitTask">提交验收</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,130 @@
|
||||
.scrollarea {
|
||||
height: 100vh;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.header,
|
||||
.row,
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin-top: 8rpx;
|
||||
color: #667085;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin: 28rpx 0 20rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1rpx solid #d0d5dd;
|
||||
background: #ffffff;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: #1f6feb;
|
||||
background: #eaf2ff;
|
||||
color: #1f6feb;
|
||||
}
|
||||
|
||||
.stats {
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
color: #344054;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.stats-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
display: grid;
|
||||
gap: 8rpx;
|
||||
min-width: 0;
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.stats-card text {
|
||||
color: #667085;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.stats-card strong {
|
||||
color: #101828;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 28rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin: 18rpx 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #1f6feb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #b42318;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.submit-box {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin: 16rpx 0;
|
||||
padding: 16rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #fff4e5;
|
||||
color: #9a3412;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 20rpx 0;
|
||||
color: #c73535;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 80rpx 0;
|
||||
color: #888888;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ const {
|
||||
ensureLogin,
|
||||
cents,
|
||||
clientRequestId,
|
||||
requestWechatPayment,
|
||||
} = require('../../utils/api.js')
|
||||
|
||||
Page({
|
||||
@@ -76,6 +77,21 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
async payRechargeOrder() {
|
||||
if (!this.data.rechargeOrder || !this.data.rechargeOrder.rechargeOrderId) {
|
||||
this.setData({ errorMessage: '请先创建充值单' })
|
||||
return
|
||||
}
|
||||
await this.withRequest(async () => {
|
||||
const response = await request(
|
||||
`/recharge/orders/${encodeURIComponent(this.data.rechargeOrder.rechargeOrderId)}/wechat-prepay`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
await requestWechatPayment(response.data)
|
||||
this.setData({ successMessage: '微信支付已提交,到账结果以服务端通知为准' })
|
||||
})
|
||||
},
|
||||
|
||||
async withRequest(work) {
|
||||
this.setData({ loading: true, errorMessage: '', successMessage: '' })
|
||||
try {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<view class="muted">状态:{{rechargeOrder.status}}</view>
|
||||
<view class="muted">实付:{{rechargeOrder.payText}}</view>
|
||||
<view class="muted">赠送:{{rechargeOrder.giftText}}</view>
|
||||
<button loading="{{loading}}" bindtap="payRechargeOrder">微信支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
@@ -28,12 +28,95 @@ Page({
|
||||
quote: null,
|
||||
order: null,
|
||||
payment: null,
|
||||
couponGrantId: '',
|
||||
packageHoldingId: '',
|
||||
packageMinutes: '',
|
||||
packageCreditYuan: '',
|
||||
benefitLoading: false,
|
||||
availableCoupons: [],
|
||||
availablePackages: [],
|
||||
selectedCouponName: '',
|
||||
selectedPackageName: '',
|
||||
},
|
||||
onLoad(options) {
|
||||
this.setData({
|
||||
storeId: options.storeId || '',
|
||||
roomId: options.roomId || '',
|
||||
})
|
||||
this.loadBenefitOptions()
|
||||
},
|
||||
|
||||
async loadBenefitOptions() {
|
||||
this.setData({ benefitLoading: true })
|
||||
try {
|
||||
await ensureLogin()
|
||||
const response = await request('/profile/benefits')
|
||||
this.setData({
|
||||
availableCoupons: (response.data.coupons || [])
|
||||
.filter((item) => canUseCoupon(item, this.data.storeId, this.data.roomId))
|
||||
.map(formatCouponOption),
|
||||
availablePackages: (response.data.packages || [])
|
||||
.filter((item) => canUsePackage(item, this.data.storeId, this.data.roomId))
|
||||
.map(formatPackageOption),
|
||||
})
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '权益加载失败' })
|
||||
} finally {
|
||||
this.setData({ benefitLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectCoupon(event) {
|
||||
const couponGrantId = event.currentTarget.dataset.id || ''
|
||||
const current = this.data.couponGrantId === couponGrantId
|
||||
const selected = current
|
||||
? null
|
||||
: this.data.availableCoupons.find((item) => item.couponGrantId === couponGrantId)
|
||||
this.setData({
|
||||
couponGrantId: current ? '' : couponGrantId,
|
||||
selectedCouponName: selected ? selected.name : '',
|
||||
order: null,
|
||||
payment: null,
|
||||
successMessage: '',
|
||||
errorMessage: '',
|
||||
})
|
||||
},
|
||||
|
||||
selectPackage(event) {
|
||||
const packageHoldingId = event.currentTarget.dataset.id || ''
|
||||
const current = this.data.packageHoldingId === packageHoldingId
|
||||
const selected = current
|
||||
? null
|
||||
: this.data.availablePackages.find((item) => item.packageHoldingId === packageHoldingId)
|
||||
this.setData({
|
||||
packageHoldingId: current ? '' : packageHoldingId,
|
||||
selectedPackageName: selected ? selected.name : '',
|
||||
packageMinutes: selected && selected.remainingMinutes > 0
|
||||
? String(Math.min(selected.remainingMinutes, this.data.durationMinutes))
|
||||
: '',
|
||||
packageCreditYuan: selected && selected.remainingAmountCents > 0
|
||||
? yuanInput(Math.min(
|
||||
selected.remainingAmountCents,
|
||||
this.data.quote?.totalCents || selected.remainingAmountCents
|
||||
))
|
||||
: '',
|
||||
order: null,
|
||||
payment: null,
|
||||
successMessage: '',
|
||||
errorMessage: '',
|
||||
})
|
||||
},
|
||||
|
||||
updatePackageUsage(event) {
|
||||
const field = event.currentTarget.dataset.field
|
||||
if (!field) return
|
||||
this.setData({
|
||||
[field]: event.detail.value,
|
||||
order: null,
|
||||
payment: null,
|
||||
successMessage: '',
|
||||
errorMessage: '',
|
||||
})
|
||||
},
|
||||
|
||||
setDuration(event) {
|
||||
@@ -68,7 +151,9 @@ Page({
|
||||
const response = await request('/orders/reserve', { method: 'POST', data: payload })
|
||||
this.setData({
|
||||
order: response.data,
|
||||
successMessage: '时段已预占,请继续支付',
|
||||
successMessage: response.data.benefitReservation
|
||||
? '时段已预占,权益已冻结,请继续支付'
|
||||
: '时段已预占,请继续支付',
|
||||
})
|
||||
})
|
||||
},
|
||||
@@ -78,6 +163,51 @@ Page({
|
||||
this.setData({ errorMessage: '请先预占订单' })
|
||||
return
|
||||
}
|
||||
|
||||
function canUseCoupon(item, storeId, roomId) {
|
||||
return item.status === 'AVAILABLE'
|
||||
&& Number(item.remainingUses || 0) > 0
|
||||
&& (!item.storeId || String(item.storeId) === String(storeId))
|
||||
&& (!item.roomId || String(item.roomId) === String(roomId))
|
||||
}
|
||||
|
||||
function canUsePackage(item, storeId, roomId) {
|
||||
return item.status === 'ACTIVE'
|
||||
&& (Number(item.remainingMinutes || 0) > 0 || Number(item.remainingAmountCents || 0) > 0)
|
||||
&& (!item.storeId || String(item.storeId) === String(storeId))
|
||||
&& (!item.roomId || String(item.roomId) === String(roomId))
|
||||
}
|
||||
|
||||
function formatCouponOption(item) {
|
||||
const isTime = item.couponType === 'TIME'
|
||||
return {
|
||||
...item,
|
||||
valueText: isTime ? `${item.timeMinutes || 0}分钟` : cents(item.discountAmountCents),
|
||||
thresholdText: Number(item.minOrderAmountCents || 0) > 0
|
||||
? `满${cents(item.minOrderAmountCents)}可用`
|
||||
: '无门槛',
|
||||
scopeText: scopeText(item),
|
||||
}
|
||||
}
|
||||
|
||||
function formatPackageOption(item) {
|
||||
return {
|
||||
...item,
|
||||
remainingAmountText: cents(item.remainingAmountCents),
|
||||
scopeText: scopeText(item),
|
||||
}
|
||||
}
|
||||
|
||||
function scopeText(item) {
|
||||
if (item.roomId) return `房间 ${item.roomId}`
|
||||
if (item.roomCategoryId) return `房型 ${item.roomCategoryId}`
|
||||
if (item.storeId) return `门店 ${item.storeId}`
|
||||
return item.holidayOnly ? '节假日' : '通用'
|
||||
}
|
||||
|
||||
function yuanInput(centsValue) {
|
||||
return (Number(centsValue || 0) / 100).toFixed(2)
|
||||
}
|
||||
await this.withRequest(async () => {
|
||||
const response = await request('/payments', {
|
||||
method: 'POST',
|
||||
@@ -126,6 +256,30 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
async createBalancePayment() {
|
||||
if (!this.data.order || !this.data.order.orderId) {
|
||||
this.setData({ errorMessage: '请先预占订单' })
|
||||
return
|
||||
}
|
||||
await this.withRequest(async () => {
|
||||
const response = await request('/payments', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
orderId: this.data.order.orderId,
|
||||
provider: 'BALANCE',
|
||||
clientRequestId: clientRequestId('miniapp-balance'),
|
||||
},
|
||||
})
|
||||
this.setData({
|
||||
payment: {
|
||||
...response.data,
|
||||
amountText: cents(response.data.amountCents),
|
||||
},
|
||||
successMessage: '余额支付已完成',
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async completeTestPayment() {
|
||||
if (!this.data.payment || !this.data.payment.paymentId) {
|
||||
this.setData({ errorMessage: '请先创建测试支付单' })
|
||||
@@ -156,6 +310,22 @@ Page({
|
||||
startAt: start.toISOString(),
|
||||
endAt: isoMinutesAfter(start, this.data.durationMinutes),
|
||||
pricingMode: this.data.pricingMode,
|
||||
benefits: this.benefitsPayload(),
|
||||
}
|
||||
},
|
||||
|
||||
benefitsPayload() {
|
||||
const couponGrantId = String(this.data.couponGrantId || '').trim()
|
||||
const packageHoldingId = String(this.data.packageHoldingId || '').trim()
|
||||
if (!couponGrantId && !packageHoldingId) return undefined
|
||||
const packageMinutes = Number(this.data.packageMinutes || 0)
|
||||
const packageCreditCents = Math.round(Number(this.data.packageCreditYuan || 0) * 100)
|
||||
return {
|
||||
couponGrantId: couponGrantId || null,
|
||||
packageHoldingId: packageHoldingId || null,
|
||||
packageMinutes: packageMinutes > 0 ? packageMinutes : 0,
|
||||
packageCreditCents: packageCreditCents > 0 ? packageCreditCents : 0,
|
||||
clientRequestId: clientRequestId('miniapp-benefit'),
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -9,10 +9,49 @@
|
||||
<button size="mini" class="{{durationMinutes === 180 ? 'selected' : ''}}" data-minutes="180" bindtap="setDuration">3小时</button>
|
||||
</view>
|
||||
|
||||
<view class="section-title">权益抵扣</view>
|
||||
<view wx:if="{{benefitLoading}}" class="muted">权益加载中</view>
|
||||
<view wx:if="{{!benefitLoading && availableCoupons.length === 0 && availablePackages.length === 0}}" class="muted">暂无可用权益</view>
|
||||
<view wx:if="{{availableCoupons.length}}" class="benefit-list">
|
||||
<view
|
||||
wx:for="{{availableCoupons}}"
|
||||
wx:key="couponGrantId"
|
||||
class="benefit-option {{couponGrantId === item.couponGrantId ? 'active' : ''}}"
|
||||
data-id="{{item.couponGrantId}}"
|
||||
bindtap="selectCoupon"
|
||||
>
|
||||
<view class="option-head">
|
||||
<view class="option-title">{{item.name}}</view>
|
||||
<view class="option-value">{{item.valueText}}</view>
|
||||
</view>
|
||||
<view class="muted">{{item.thresholdText}} · {{item.scopeText}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{availablePackages.length}}" class="benefit-list">
|
||||
<view
|
||||
wx:for="{{availablePackages}}"
|
||||
wx:key="packageHoldingId"
|
||||
class="benefit-option {{packageHoldingId === item.packageHoldingId ? 'active' : ''}}"
|
||||
data-id="{{item.packageHoldingId}}"
|
||||
bindtap="selectPackage"
|
||||
>
|
||||
<view class="option-head">
|
||||
<view class="option-title">{{item.name}}</view>
|
||||
<view class="option-value">{{item.remainingAmountText}}</view>
|
||||
</view>
|
||||
<view class="muted">剩余 {{item.remainingMinutes}} 分钟 · {{item.scopeText}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{packageHoldingId}}" class="benefit-grid">
|
||||
<input placeholder="套餐分钟" type="number" value="{{packageMinutes}}" data-field="packageMinutes" bindinput="updatePackageUsage" />
|
||||
<input placeholder="套餐抵扣元" type="digit" value="{{packageCreditYuan}}" data-field="packageCreditYuan" bindinput="updatePackageUsage" />
|
||||
</view>
|
||||
|
||||
<view class="action-grid">
|
||||
<button loading="{{loading}}" bindtap="quote">报价</button>
|
||||
<button loading="{{loading}}" bindtap="reserve">预占</button>
|
||||
<button loading="{{loading}}" bindtap="createWechatPayment">微信支付</button>
|
||||
<button loading="{{loading}}" bindtap="createBalancePayment">余额支付</button>
|
||||
<button loading="{{loading}}" bindtap="createTestPayment">测试支付</button>
|
||||
<button loading="{{loading}}" bindtap="completeTestPayment">完成测试支付</button>
|
||||
</view>
|
||||
@@ -30,6 +69,10 @@
|
||||
<view class="result-title">订单</view>
|
||||
<view>订单编号:{{order.orderId}}</view>
|
||||
<view wx:if="{{order.reservationId}}">预占编号:{{order.reservationId}}</view>
|
||||
<view wx:if="{{selectedCouponName}}">已选优惠券:{{selectedCouponName}}</view>
|
||||
<view wx:if="{{selectedPackageName}}">已选套餐:{{selectedPackageName}}</view>
|
||||
<view wx:if="{{order.quote.discountCents}}">优惠抵扣:{{order.quote.discountCents}}分</view>
|
||||
<view wx:if="{{order.quote.packageCreditCents}}">套餐抵扣:{{order.quote.packageCreditCents}}分</view>
|
||||
<button size="mini" data-order-id="{{order.orderId}}" bindtap="openOrder">查看订单</button>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -22,13 +22,64 @@
|
||||
}
|
||||
|
||||
.duration-row,
|
||||
.action-grid {
|
||||
.action-grid,
|
||||
.benefit-grid {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.benefit-grid input {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 280rpx;
|
||||
min-height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid #d9dde5;
|
||||
border-radius: 12rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.benefit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.benefit-option {
|
||||
padding: 22rpx;
|
||||
border: 2rpx solid #d9dde5;
|
||||
border-radius: 12rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.benefit-option.active {
|
||||
border-color: #1f6feb;
|
||||
background: #eef5ff;
|
||||
}
|
||||
|
||||
.option-head {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.option-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.option-value {
|
||||
flex: 0 0 auto;
|
||||
color: #1f6feb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-grid button {
|
||||
flex: 1 1 280rpx;
|
||||
}
|
||||
@@ -56,6 +107,11 @@
|
||||
color: #17823b;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #777777;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 16rpx 0;
|
||||
color: #c73535;
|
||||
|
||||
@@ -26,6 +26,43 @@ function request(path, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function uploadImage(path, filePath, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync(TOKEN_KEY)
|
||||
wx.getFileSystemManager().readFile({
|
||||
filePath,
|
||||
success(result) {
|
||||
wx.request({
|
||||
url: `${APP_API_BASE_URL}${path}`,
|
||||
method: 'POST',
|
||||
data: result.data,
|
||||
header: {
|
||||
'x-wechat-appid': WECHAT_APP_ID,
|
||||
'x-file-name': filePath.split('/').pop() || `cleaning-${Date.now()}.jpg`,
|
||||
'x-image-content-type': guessImageType(filePath),
|
||||
'content-type': 'application/octet-stream',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
success(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
try {
|
||||
resolve(typeof response.data === 'string' ? JSON.parse(response.data) : response.data)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
reject(new Error(response.data?.message || `上传失败:${response.statusCode}`))
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function login() {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.login({
|
||||
@@ -82,8 +119,16 @@ function requestWechatPayment(params) {
|
||||
})
|
||||
}
|
||||
|
||||
function guessImageType(filePath) {
|
||||
const lower = String(filePath || '').toLowerCase()
|
||||
if (lower.endsWith('.png')) return 'image/png'
|
||||
if (lower.endsWith('.webp')) return 'image/webp'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
uploadImage,
|
||||
login,
|
||||
ensureLogin,
|
||||
clearSession,
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
||||
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||
|
||||
const packageJson = JSON.parse(read('admin/package.json'));
|
||||
assert.equal(packageJson.dependencies.vue.startsWith('^3.'), true);
|
||||
assert.ok(packageJson.dependencies['element-plus']);
|
||||
assert.ok(packageJson.dependencies['@lucide/vue']);
|
||||
|
||||
const envExample = read('admin/.env.example');
|
||||
assert.match(envExample, /https:\/\/api\.txyundm\.cn\/admin-api/);
|
||||
assert.doesNotMatch(envExample, /localhost|127\.0\.0\.1|http:\/\//);
|
||||
|
||||
const vite = read('admin/vite.config.ts');
|
||||
assert.match(vite, /base: '\/admin\/'/);
|
||||
assert.match(vite, /'\/admin-api'/);
|
||||
|
||||
const app = read('admin/src/App.vue');
|
||||
for (const pattern of [
|
||||
'CleaningTasksPanel',
|
||||
'CleaningSettlementsPanel',
|
||||
'CleaningStatisticsPanel',
|
||||
'CleanersPanel',
|
||||
'qipai.admin.token',
|
||||
'loadTasks',
|
||||
'loadSettlements',
|
||||
'loadStatistics',
|
||||
'loadCleaners',
|
||||
'handleSyncTransfer',
|
||||
'handleSyncTransferMany',
|
||||
'handleCompleteMany',
|
||||
'handleRejectMany',
|
||||
'handleExempt',
|
||||
'handleExemptMany',
|
||||
'运营待处理',
|
||||
'jumpToTaskStatus',
|
||||
'jumpToSettlementStatus',
|
||||
'draftSettlementTotal',
|
||||
'operationalQueueTotal',
|
||||
'exportOperationalQueue',
|
||||
'cleaning-operational-queue',
|
||||
'导出待处理',
|
||||
'settlementFilters.payoutState',
|
||||
'待确认结算',
|
||||
'待发放金额',
|
||||
'CleaningFieldHandoffPanel',
|
||||
'联调'
|
||||
]) {
|
||||
assert.match(app, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const api = read('admin/src/api.ts');
|
||||
for (const route of [
|
||||
'/cleaning/tasks?',
|
||||
'/cleaning/statistics',
|
||||
'storeId',
|
||||
'cleanerUserId',
|
||||
'/users?',
|
||||
'/staff',
|
||||
'/reset-sessions',
|
||||
'/assign',
|
||||
'/complete',
|
||||
'/reject',
|
||||
'/exempt',
|
||||
'/members',
|
||||
'/cleaning/reclaim-timeouts',
|
||||
'/cleaning/settlements?',
|
||||
'/cleaning/settlements',
|
||||
'payoutState',
|
||||
'/wechat-transfer',
|
||||
'/wechat-transfer/preflight',
|
||||
'/wechat-transfer/sync',
|
||||
'/payout-failure'
|
||||
]) {
|
||||
assert.match(api, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const tasks = read('admin/src/components/CleaningTasksPanel.vue');
|
||||
for (const pattern of [
|
||||
'指派',
|
||||
'验收',
|
||||
'驳回',
|
||||
'回收',
|
||||
'成员',
|
||||
'保洁任务详情',
|
||||
'操作流水',
|
||||
'协作分账',
|
||||
'listCleaningTaskEvents',
|
||||
'listCleaningTaskMembers',
|
||||
'loadingEvents',
|
||||
'loadingMembers',
|
||||
'批量验收',
|
||||
'批量驳回',
|
||||
'selectedSubmittedTasks',
|
||||
'selectedExemptableTasks',
|
||||
'selectedExemptableCount',
|
||||
'complete-many',
|
||||
'reject-many',
|
||||
'exempt-many',
|
||||
'exempt',
|
||||
'canExempt',
|
||||
'\\u6279\\u91cf\\u514d\\u6e05\\u6d01',
|
||||
'EXEMPT',
|
||||
'photo-grid',
|
||||
'timeline-grid',
|
||||
'el-image',
|
||||
'协作者管理',
|
||||
'photoUrls',
|
||||
'el-select',
|
||||
'cleanerLabel',
|
||||
'filterDraft',
|
||||
'门店 ID',
|
||||
'筛选',
|
||||
'导出',
|
||||
'exportTasks',
|
||||
'exportTaskDetail',
|
||||
'exportTaskEvents',
|
||||
'exportTaskMembers',
|
||||
'cleaning-tasks',
|
||||
'cleaning-task-detail',
|
||||
'cleaning-task-events',
|
||||
'cleaning-task-members',
|
||||
'导出详情',
|
||||
'导出成员',
|
||||
'downloadCsv',
|
||||
'csvCell',
|
||||
'photoUrls?.length'
|
||||
]) {
|
||||
assert.match(tasks, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const settlements = read('admin/src/components/CleaningSettlementsPanel.vue');
|
||||
for (const pattern of [
|
||||
'详情',
|
||||
'生成',
|
||||
'确认',
|
||||
'转账',
|
||||
'同步',
|
||||
'批量同步',
|
||||
'selectedSyncableSettlements',
|
||||
'sync-transfer-many',
|
||||
'失败',
|
||||
'结算单详情',
|
||||
'微信确认参数',
|
||||
'payoutPackageInfo',
|
||||
'getCleaningSettlementDetail',
|
||||
'微信转账预检',
|
||||
'preflightWechatTransfer',
|
||||
'凭据引用',
|
||||
'授权状态',
|
||||
'通知URL',
|
||||
'平台证书',
|
||||
'保洁员OpenID',
|
||||
'credentialRefMasked',
|
||||
'platformCertificateCount',
|
||||
'configuredText',
|
||||
'导出预检',
|
||||
'exportPreflight',
|
||||
'cleaning-transfer-preflight',
|
||||
'transferPreflightTitle',
|
||||
'transferCanSubmit',
|
||||
'preflightLoading',
|
||||
'listCleaningSettlementCandidates',
|
||||
'candidateDialog',
|
||||
'useCandidateFilters',
|
||||
'exportSettlements',
|
||||
'exportCandidates',
|
||||
'exportSettlementDetail',
|
||||
'cleaning-settlement-detail',
|
||||
'downloadCsv',
|
||||
'MOCK',
|
||||
'el-select',
|
||||
'cleanerLabel',
|
||||
'filterDraft',
|
||||
'payoutStateOptions',
|
||||
'转账状态',
|
||||
'WAIT_USER_CONFIRM',
|
||||
'门店 ID',
|
||||
'筛选'
|
||||
]) {
|
||||
assert.match(settlements, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const statistics = read('admin/src/components/CleaningStatisticsPanel.vue');
|
||||
for (const pattern of [
|
||||
'今日',
|
||||
'近7天',
|
||||
'本月',
|
||||
'任务状态',
|
||||
'结算口径',
|
||||
'每日趋势',
|
||||
'trendWidth',
|
||||
'paidSettlementCents',
|
||||
'门店明细',
|
||||
'成员分账',
|
||||
'保洁员绩效排行',
|
||||
'cleanerPerformanceRows',
|
||||
'completionRate',
|
||||
'filterByCleaner',
|
||||
'exportCleanerPerformance',
|
||||
'cleaning-cleaner-performance',
|
||||
'completedTaskCount',
|
||||
'rejectedTaskCount',
|
||||
'exempted',
|
||||
'导出排行',
|
||||
'pendingSettlementCents',
|
||||
'filterDraft.cleanerUserId',
|
||||
'cleanerLabel',
|
||||
'exportStatistics',
|
||||
'cleaning-statistics',
|
||||
'downloadCsv',
|
||||
'csvCell',
|
||||
'el-select'
|
||||
]) {
|
||||
assert.match(statistics, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const cleaningRepository = read('backend/src/cleaning/cleaning-task-repository.ts');
|
||||
for (const pattern of [
|
||||
'completedTaskCount',
|
||||
'rejectedTaskCount',
|
||||
"t.status IN ('COMPLETED', 'SETTLED')",
|
||||
"t.status = 'REJECTED'",
|
||||
'async exempt',
|
||||
"status = 'EXEMPT'",
|
||||
'reward_cents = 0',
|
||||
'AS exempted',
|
||||
"countStatus(byStatus, 'EXEMPT')"
|
||||
]) {
|
||||
assert.match(cleaningRepository, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const cleaners = read('admin/src/components/CleanersPanel.vue');
|
||||
for (const pattern of [
|
||||
'新增保洁员',
|
||||
'门店 ID,逗号分隔',
|
||||
'已绑微信',
|
||||
'待补资料',
|
||||
'profileComplete',
|
||||
'profileIssues',
|
||||
'exportCleaners',
|
||||
'exportCleanerProfileIssues',
|
||||
'cleaning-cleaners',
|
||||
'cleaning-cleaner-profile-issues',
|
||||
'导出待补',
|
||||
'downloadCsv',
|
||||
'csvCell',
|
||||
'会话',
|
||||
'status-toggle',
|
||||
'reset-sessions'
|
||||
]) {
|
||||
assert.match(cleaners, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const handoff = read('admin/src/components/CleaningFieldHandoffPanel.vue');
|
||||
for (const pattern of [
|
||||
'现场联调进度',
|
||||
'qipai.cleaning.field-handoff',
|
||||
'qipai.cleaning.field-handoff.session',
|
||||
'联调批次',
|
||||
'现场/门店',
|
||||
'验收人',
|
||||
'收口结论',
|
||||
'下一步',
|
||||
'acceptor',
|
||||
'closeoutVerdict',
|
||||
'nextAction',
|
||||
'createEmptySessionMeta',
|
||||
'最后更新',
|
||||
'证据记录',
|
||||
'evidenceRecordedCount',
|
||||
'待补证据',
|
||||
'evidenceMissingItems',
|
||||
'evidenceMissingCount',
|
||||
'overdueItems',
|
||||
'isOverdueDraft',
|
||||
'parseDeadline',
|
||||
'copyOverdueSummary',
|
||||
'逾期检查项',
|
||||
'逾期清单已复制',
|
||||
'copyEvidenceSummary',
|
||||
'待补证据清单已复制',
|
||||
'现场交接报告已复制',
|
||||
'copyHandoffReport',
|
||||
'exportHandoffReport',
|
||||
'exportCloseoutArchive',
|
||||
'buildCloseoutArchive',
|
||||
'serializeHandoffItem',
|
||||
'buildHandoffReport',
|
||||
'formatReportItem',
|
||||
'statusText',
|
||||
'cleaning-field-handoff-report',
|
||||
'cleaning-field-closeout-archive',
|
||||
'qipai.cleaning.field-handoff.closeout-archive.v1',
|
||||
'归档包',
|
||||
'evidenceRef',
|
||||
'normalizeDraft',
|
||||
'sessionMeta',
|
||||
'updatedAt',
|
||||
'persistSessionMeta',
|
||||
'formatLocalMinute',
|
||||
'快照',
|
||||
'导入',
|
||||
'exportSnapshot',
|
||||
'importSnapshot',
|
||||
'qipai.cleaning.field-handoff.snapshot.v1',
|
||||
'cleaning-field-handoff-snapshot',
|
||||
'categoryProgress',
|
||||
'passPercent',
|
||||
'handoff-category-progress',
|
||||
'stageProgress',
|
||||
'copyStageSummary',
|
||||
'执行阶段看板',
|
||||
'阶段摘要已复制',
|
||||
'可进入现场验收收口',
|
||||
'handoffReady',
|
||||
'missingCloseoutFields',
|
||||
'closeoutReady',
|
||||
'copyVerdict',
|
||||
'copyCloseoutGate',
|
||||
'收口门禁已复制',
|
||||
'归档门禁',
|
||||
'现场记录可归档',
|
||||
'收口资料待补齐',
|
||||
'检查项状态',
|
||||
'现场证据',
|
||||
'联调结论已复制',
|
||||
'商户凭据引用',
|
||||
'小程序合法域名',
|
||||
'小额真实转账',
|
||||
'订单权限开门',
|
||||
'控电和插座联动',
|
||||
'BLOCKED',
|
||||
'statusFilter',
|
||||
'handoff-status-select',
|
||||
'全部状态',
|
||||
'ownerFilter',
|
||||
'keywordFilter',
|
||||
'matchesKeyword',
|
||||
'FilterX',
|
||||
'hasActiveFilters',
|
||||
'clearHandoffFilters',
|
||||
'\\u6e05\\u9664\\u7b5b\\u9009',
|
||||
'ownerOptions',
|
||||
'ownerProgress',
|
||||
'copyOwnerSummary',
|
||||
'负责人看板',
|
||||
'负责人摘要已复制',
|
||||
'handoff-owner-select',
|
||||
'handoff-search',
|
||||
'\\u641c\\u7d22\\u68c0\\u67e5\\u9879/\\u8d1f\\u8d23\\u4eba/\\u8bc1\\u636e',
|
||||
'全部负责人',
|
||||
'未分派',
|
||||
'批量标记',
|
||||
'bulkForm',
|
||||
'deadline',
|
||||
'期限',
|
||||
'YYYY-MM-DD',
|
||||
'applyBulkMark',
|
||||
'应用到当前筛选',
|
||||
'阻断摘要',
|
||||
'blockedItems',
|
||||
'riskItems',
|
||||
'copyBlockedSummary',
|
||||
'navigator.clipboard.writeText',
|
||||
'exportChecklist',
|
||||
'导出筛选',
|
||||
'exportItems',
|
||||
'buildFilterSummary',
|
||||
'当前筛选条件',
|
||||
'导出检查项数',
|
||||
'exportRiskRegister',
|
||||
'cleaning-field-handoff',
|
||||
'cleaning-field-risk-register',
|
||||
'缺证据',
|
||||
'localStorage',
|
||||
'downloadCsv'
|
||||
]) {
|
||||
assert.match(handoff, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const styles = read('admin/src/styles.css');
|
||||
assert.match(styles, /\.cleaner-select/);
|
||||
assert.match(styles, /\.option-row/);
|
||||
assert.match(styles, /\.filter-select/);
|
||||
assert.match(styles, /\.narrow-input/);
|
||||
assert.match(styles, /\.action-board/);
|
||||
assert.match(styles, /\.action-tile/);
|
||||
assert.match(styles, /\.handoff-summary/);
|
||||
assert.match(styles, /\.handoff-body/);
|
||||
assert.match(styles, /\.handoff-session/);
|
||||
assert.match(styles, /\.handoff-category-progress/);
|
||||
assert.match(styles, /\.handoff-category-row/);
|
||||
assert.match(styles, /\.handoff-stage-board/);
|
||||
assert.match(styles, /\.handoff-stage-grid/);
|
||||
assert.match(styles, /\.handoff-stage-card/);
|
||||
assert.match(styles, /\.handoff-owner-board/);
|
||||
assert.match(styles, /\.handoff-owner-grid/);
|
||||
assert.match(styles, /\.handoff-owner-card/);
|
||||
assert.match(styles, /\.handoff-search/);
|
||||
assert.match(styles, /\.handoff-verdict/);
|
||||
assert.match(styles, /\.handoff-status-select/);
|
||||
assert.match(styles, /\.handoff-bulk-form/);
|
||||
assert.match(styles, /\.handoff-blockers/);
|
||||
assert.match(styles, /\.cleaner-performance-board/);
|
||||
assert.match(styles, /\.cleaner-performance-grid/);
|
||||
assert.match(styles, /\.cleaner-performance-card/);
|
||||
assert.match(styles, /\.hidden-file-input/);
|
||||
assert.match(styles, /\.transfer-preflight-alert/);
|
||||
assert.match(styles, /@media \(max-width: 980px\)/);
|
||||
assert.match(styles, /@media \(max-width: 560px\)/);
|
||||
|
||||
console.log('PASS: M08-B admin cleaning workspace covers task, settlement, transfer and preflight controls.');
|
||||
@@ -14,7 +14,8 @@ for (const page of [
|
||||
'pages/orders/detail',
|
||||
'pages/profile/index',
|
||||
'pages/benefits/index',
|
||||
'pages/recharge/index'
|
||||
'pages/recharge/index',
|
||||
'pages/cleaner/tasks'
|
||||
]) {
|
||||
assert.ok(appJson.pages.includes(page), `${page} must be registered`);
|
||||
}
|
||||
@@ -48,6 +49,19 @@ for (const route of [
|
||||
assert.match(room, new RegExp(route.replace('/', '\\/')));
|
||||
}
|
||||
assert.match(room, /requestWechatPayment/);
|
||||
for (const pattern of [
|
||||
'couponGrantId',
|
||||
'packageHoldingId',
|
||||
'packageCreditCents',
|
||||
'/profile/benefits',
|
||||
'selectCoupon',
|
||||
'selectPackage',
|
||||
'availableCoupons',
|
||||
'availablePackages',
|
||||
"provider: 'BALANCE'"
|
||||
]) {
|
||||
assert.match(room, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const orders = read('miniapp/pages/orders/list.js')
|
||||
+ read('miniapp/pages/orders/detail.js');
|
||||
@@ -94,6 +108,8 @@ const recharge = read('miniapp/pages/recharge/index.js')
|
||||
for (const pattern of [
|
||||
'/recharge/plans',
|
||||
'/recharge/orders',
|
||||
'/wechat-prepay',
|
||||
'requestWechatPayment',
|
||||
'clientRequestId',
|
||||
'selectedPlanId',
|
||||
'rechargeOrder'
|
||||
@@ -101,4 +117,29 @@ for (const pattern of [
|
||||
assert.match(recharge, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
console.log('PASS: M08-A miniapp customer pages use fixed domain and real app-api calls.');
|
||||
const cleaner = read('miniapp/pages/cleaner/tasks.js')
|
||||
+ read('miniapp/pages/cleaner/tasks.wxml')
|
||||
+ read('miniapp/pages/cleaner/tasks.json');
|
||||
for (const pattern of [
|
||||
'/cleaning/tasks/hall',
|
||||
'/cleaning/tasks/mine',
|
||||
'/cleaning/stats',
|
||||
'/cleaning/tasks/${encodeURIComponent(taskId)}/${action}',
|
||||
'claimTask',
|
||||
'startTask',
|
||||
'submitTask',
|
||||
'photoUrls',
|
||||
'pendingSettlementText',
|
||||
'settledRewardText',
|
||||
'completionRate',
|
||||
'stats-board',
|
||||
'任务总数',
|
||||
'完成率',
|
||||
'已结算',
|
||||
'驳回',
|
||||
'保洁任务'
|
||||
]) {
|
||||
assert.match(cleaner, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
console.log('PASS: M08-A/M08-B miniapp customer and cleaner pages use fixed domain and real app-api calls.');
|
||||
|
||||
@@ -9,6 +9,13 @@ $ErrorActionPreference = "Stop"
|
||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-status-docs.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-readme-config.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
||||
node scripts/check-admin-m08-b.mjs
|
||||
|
||||
if (Test-Path "admin/package.json") {
|
||||
Push-Location admin
|
||||
npm run build
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if (Test-Path "package.json") {
|
||||
npm run lint --if-present
|
||||
|
||||
Reference in New Issue
Block a user