Compare commits

...

139 Commits

Author SHA1 Message Date
Codex 01e86e6856 docs(M08-B): 回填联调筛选清除远端校验 2026-07-15 00:30:09 +08:00
Codex 3bee3c47b6 docs(M08-B): 回填联调筛选清除工程提交 2026-07-15 00:29:23 +08:00
Codex 96c5fa4c90 feat(M08-B): 补联调筛选清除 2026-07-15 00:25:30 +08:00
Codex 1a1892cfb4 docs(M08-B): 回填联调筛选导出远端校验 2026-07-08 10:49:42 +08:00
Codex 06ea496eb7 docs(M08-B): 回填联调筛选导出工程提交 2026-07-08 10:48:38 +08:00
Codex de0013e329 feat(M08-B): 补联调筛选导出 2026-07-08 10:43:44 +08:00
Codex dabb71d8ab docs(M08-B): 回填联调搜索远端校验 2026-07-08 10:38:18 +08:00
Codex 0f85422e82 docs(M08-B): 回填联调搜索工程提交 2026-07-08 10:36:55 +08:00
Codex 053e27fd40 feat(M08-B): 补联调检查项搜索 2026-07-08 10:31:19 +08:00
Codex a4c2d14c41 docs(M08-B): 回填批量免清洁远端校验 2026-07-08 10:26:11 +08:00
Codex c40cbcac11 docs(M08-B): 回填批量免清洁工程提交 2026-07-08 10:25:17 +08:00
Codex eb838f5a7e feat(M08-B): 补批量免清洁操作 2026-07-08 10:19:23 +08:00
Codex 0bdcb220e5 docs(M08-B): 回填免清洁统计展示工程提交 2026-07-08 10:13:33 +08:00
Codex 642659ce3e feat(M08-B): 补免清洁统计展示 2026-07-08 10:11:35 +08:00
Codex 19d6734b68 docs(M08-B): 回填任务免清洁工程提交 2026-07-07 15:10:37 +08:00
Codex 8ffb940bdb feat(M08-B): 补任务免清洁 2026-07-07 15:08:32 +08:00
Codex 2a4db54b94 docs(M08-B): 回填保洁员端统计看板工程提交 2026-07-07 14:49:02 +08:00
Codex 6979614aeb feat(M08-B): 补保洁员端统计看板 2026-07-07 14:48:19 +08:00
Codex eb0f58912e docs(M08-B): 回填保洁员绩效排行工程提交 2026-07-07 14:42:07 +08:00
Codex 38410b1185 feat(M08-B): 补保洁员绩效排行 2026-07-07 14:41:35 +08:00
Codex ee39e98418 docs(M08-B): 回填联调执行阶段看板工程提交 2026-07-07 14:33:46 +08:00
Codex 3713ddd1aa feat(M08-B): 补联调执行阶段看板 2026-07-07 14:33:15 +08:00
Codex 31052625ba docs(M08-B): 回填联调负责人看板工程提交 2026-07-07 14:27:35 +08:00
Codex b66b8d4d78 feat(M08-B): 补联调负责人看板 2026-07-07 14:26:54 +08:00
Codex 650b9b917a docs(M08-B): 回填联调逾期清单工程提交 2026-07-07 09:53:06 +08:00
Codex 85d65068e7 feat(M08-B): 补联调逾期清单 2026-07-07 09:52:28 +08:00
Codex e490637179 docs(M08-B): 回填联调期限记录工程提交 2026-07-07 09:45:53 +08:00
Codex bcc89addba feat(M08-B): 补联调期限记录 2026-07-07 09:45:15 +08:00
Codex 7e8fa275d5 docs(M08-B): 回填联调负责人筛选工程提交 2026-07-07 09:38:45 +08:00
Codex 8a17659bdf feat(M08-B): 补联调负责人筛选 2026-07-07 09:38:07 +08:00
Codex 98783714a3 docs(M08-B): 回填联调归档包导出工程提交 2026-07-07 09:33:12 +08:00
Codex 9d8fb3db6b feat(M08-B): 补联调归档包导出 2026-07-07 09:32:25 +08:00
Codex c026ef7ebe docs(M08-B): 回填联调风险清单导出工程提交 2026-07-07 09:27:03 +08:00
Codex dc27897ea4 feat(M08-B): 补联调风险清单导出 2026-07-07 09:26:27 +08:00
Codex 654f41fa40 docs(M08-B): 回填保洁员待补资料导出工程提交 2026-07-07 09:19:38 +08:00
Codex 05cf27f112 feat(M08-B): 补保洁员待补资料导出 2026-07-07 09:18:59 +08:00
Codex b643fda99c docs(M08-B): 回填运营待处理导出工程提交 2026-07-06 15:10:59 +08:00
Codex 1c1039ae0e feat(M08-B): 补运营待处理导出 2026-07-06 15:10:17 +08:00
Codex 0d0bdf20d5 docs(M08-B): 回填结算详情导出工程提交 2026-07-06 15:04:04 +08:00
Codex 6961193857 feat(M08-B): 补结算详情导出 2026-07-06 15:03:17 +08:00
Codex 8c60c35444 docs(M08-B): 回填任务成员导出工程提交 2026-07-06 14:57:51 +08:00
Codex a720c05c9d feat(M08-B): 补任务成员导出 2026-07-06 14:57:09 +08:00
Codex d0cf00309f docs(M08-B): 回填任务流水导出工程提交 2026-07-06 14:51:00 +08:00
Codex 2d07c92a88 feat(M08-B): 补任务流水导出 2026-07-06 14:50:19 +08:00
Codex 38ef98b06b docs(M08-B): 回填保洁统计导出工程提交 2026-07-06 14:43:58 +08:00
Codex a067c1d020 feat(M08-B): 补保洁统计导出 2026-07-06 14:43:19 +08:00
Codex 6c977632c0 docs(M08-B): 回填保洁员列表导出工程提交 2026-07-06 14:34:23 +08:00
Codex a09683b82d feat(M08-B): 补保洁员列表导出 2026-07-06 14:33:46 +08:00
Codex 7a4797021b docs(M08-B): 回填联调收口门禁工程提交 2026-07-06 08:56:32 +08:00
Codex a1adf4d6ab feat(M08-B): 补联调收口门禁 2026-07-06 08:55:49 +08:00
Codex 300e23c93c docs(M08-B): 回填联调收口签署工程提交 2026-07-06 08:49:13 +08:00
Codex 2e200842c9 feat(M08-B): 补联调收口签署 2026-07-06 08:48:51 +08:00
Codex 31aed4835e docs(M08-B): 回填联调交接报告工程提交 2026-07-06 08:43:48 +08:00
Codex 464566a5e0 feat(M08-B): 补联调交接报告 2026-07-06 08:43:19 +08:00
Codex 99d9d862b7 docs(M08-B): 回填联调待补证据工程提交 2026-07-06 08:38:18 +08:00
Codex 9f718ab154 feat(M08-B): 补联调待补证据清单 2026-07-06 08:37:53 +08:00
Codex 1e930597b1 docs(M08-B): 回填联调证据记录工程提交 2026-07-06 08:32:53 +08:00
Codex a9bb06f732 feat(M08-B): 补联调证据记录 2026-07-06 08:32:33 +08:00
Codex a8c3fde95a docs(M08-B): 回填联调更新时间工程提交 2026-07-06 08:27:25 +08:00
Codex 14fcfc2578 feat(M08-B): 补联调更新时间 2026-07-06 08:26:46 +08:00
Codex 89b5263352 docs(M08-B): 回填联调验收结论工程提交 2026-07-06 08:21:27 +08:00
Codex 0eb6349021 feat(M08-B): 补联调验收结论 2026-07-06 08:20:25 +08:00
Codex a95fdcbb93 docs(M08-B): 回填联调分类进度工程提交 2026-07-06 08:17:49 +08:00
Codex 1543a7df99 feat(M08-B): 补联调分类进度 2026-07-06 08:16:56 +08:00
Codex 86106459c4 docs(M08-B): 回填联调快照工程提交 2026-07-05 16:11:48 +08:00
Codex 06f905e792 feat(M08-B): 补联调快照导入导出 2026-07-05 16:10:54 +08:00
Codex 1f758cc29f docs(M08-B): 回填联调场次信息工程提交 2026-07-05 16:08:15 +08:00
Codex b5e7121c35 feat(M08-B): 补联调场次信息 2026-07-05 16:07:29 +08:00
Codex fda768d4ce docs(M08-B): 回填联调阻断摘要工程提交 2026-07-05 16:03:50 +08:00
Codex a44864309c feat(M08-B): 补联调阻断摘要 2026-07-05 16:02:51 +08:00
Codex 8e4264589d feat(M08-B): 补联调批量标记 2026-07-05 16:00:17 +08:00
Codex 273e429d9e feat(M08-B): 补联调状态筛选 2026-07-05 15:57:03 +08:00
Codex 435c9f3f97 feat(M08-B): 补现场联调清单 2026-07-05 15:54:57 +08:00
Codex 9ab3f5ff46 docs(M08-B): 回填任务详情导出工程提交 2026-07-05 15:44:37 +08:00
Codex 9346e7a18a feat(M08-B): 补任务详情导出 2026-07-05 15:43:45 +08:00
Codex 0b3246ae8a docs(M08-B): 回填任务导出工程提交 2026-07-05 15:40:04 +08:00
Codex a50c6afdbe feat(M08-B): 补任务列表导出 2026-07-05 15:39:02 +08:00
Codex d6bb9ccb1e docs(M08-B): 回填预检拦截工程提交 2026-07-05 15:28:21 +08:00
Codex 44af0ee155 feat(M08-B): 补微信转账预检拦截 2026-07-05 15:27:50 +08:00
Codex 52fb508e03 docs(M08-B): 回填预检导出工程提交 2026-07-05 15:15:24 +08:00
Codex 8fae70ef8e feat(M08-B): 补微信预检导出 2026-07-05 15:14:54 +08:00
Codex aa4f74360d docs(M08-B): 回填预检资料工程提交 2026-07-05 15:08:48 +08:00
Codex a264b37bd0 feat(M08-B): 补微信预检资料展示 2026-07-05 15:08:21 +08:00
Codex c1165ccfd8 docs(M08-B): 回填确认参数工程提交 2026-07-05 15:00:53 +08:00
Codex b091c7caf1 feat(M08-B): 补微信确认参数展示 2026-07-05 15:00:27 +08:00
Codex 8f7a914e7d docs(M08-B): 回填转账筛选工程提交 2026-07-05 14:55:54 +08:00
Codex 08c152aecc feat(M08-B): 补结算转账状态筛选 2026-07-05 14:55:22 +08:00
Codex 983966561f docs(M08-B): 回填运营入口工程提交 2026-07-05 14:43:51 +08:00
Codex 2fa1f079df feat(M08-B): 补运营待处理入口 2026-07-05 14:43:04 +08:00
Codex 9f6e267d43 feat(M08-B): 补结算批量同步 2026-07-02 08:10:37 +08:00
Codex 2ce59eee3b feat(M08-B): 补任务详情协作分账 2026-07-02 08:06:29 +08:00
Codex 5eff0a4a18 feat(M08-B): 补批量验收驳回 2026-07-02 08:01:40 +08:00
Codex 986a90806b feat(M08-B): 补任务事件流水 2026-06-30 18:26:53 +08:00
Codex 957a29b11e feat(M08-B): 补结算候选导出 2026-06-30 18:19:57 +08:00
Codex 67e7796652 feat(M08-B): 补待结算候选预览 2026-06-30 18:15:36 +08:00
Codex 78fb6ed2d0 feat(M08-B): 补统计保洁员筛选 2026-06-30 17:56:35 +08:00
Codex e6bb5e6dc6 feat(M08-B): 补任务详情照片预览 2026-06-30 17:48:29 +08:00
Codex e46d8c0f0a feat(M08-B): 补任务结算筛选 2026-06-30 17:30:33 +08:00
Codex dac353d184 feat(M08-B): 补保洁员选择器 2026-06-30 17:22:31 +08:00
Codex 335adcdc22 feat(M08-B): 补保洁员资料细节 2026-06-30 11:02:43 +08:00
Codex d35c39e33f feat(M08-B): 补保洁统计趋势 2026-06-30 10:52:38 +08:00
Codex 326c534ba7 feat(M08-B): 补保洁员资料管理 2026-06-30 10:40:45 +08:00
Codex e48d1f0301 feat(M08-B): 补保洁统计明细 2026-06-30 10:24:39 +08:00
Codex f59beb0e75 feat(M08-B): 补后台协作与结算详情 2026-06-30 10:02:17 +08:00
Codex 9506021b29 feat(M08-B): 补微信转账预检 2026-06-29 11:23:49 +08:00
Codex 1fada09249 feat(M08-B): 建立保洁管理后台 2026-06-29 11:06:54 +08:00
Codex a1bace2dac feat(M08-B): 补保洁微信转账回调与轮询 2026-06-29 10:47:54 +08:00
Codex b07c451700 feat(M08-B): 接入保洁微信转账适配器 2026-06-27 16:07:41 +08:00
Codex 05907a6bea feat(M08-B): 补保洁结算发放记录 2026-06-27 15:44:32 +08:00
Codex 5d224693ae docs(M08-B): 记录保洁多人协作进度 2026-06-26 17:47:39 +08:00
Codex 03496d74ab feat(M08-B): 增加保洁多人协作策略 2026-06-26 17:46:00 +08:00
Codex 1dc36d716a docs(M08-B): 记录保洁结算单进度 2026-06-26 15:52:07 +08:00
Codex 2ec4376b1c feat(M08-B): 增加保洁结算单和超时回收 2026-06-26 15:51:50 +08:00
Codex 704dca7bcf docs(M08-B): 记录保洁验收管理进度 2026-06-26 15:42:41 +08:00
Codex 59ce3eca53 feat(M08-B): 增加保洁验收和指派管理 2026-06-26 15:42:30 +08:00
Codex c8ddc8dd00 docs(M08-B): 记录保洁照片上传进度 2026-06-26 15:36:57 +08:00
Codex fe1a08880e feat(M08-B): 补保洁照片上传和自动建单 2026-06-26 15:36:22 +08:00
Codex f7e0c8d063 docs(M08-B): 记录保洁端基础流程进度 2026-06-25 23:22:03 +08:00
Codex 2a4dcd0fd8 feat(M08-B): 接入保洁任务端基础流程 2026-06-25 23:19:04 +08:00
Codex 9f7c2e4be7 docs(M08-A): 记录权益选择器进度 2026-06-25 23:05:28 +08:00
Codex 47016744d4 feat(M08-A): 增加权益可视化选择器 2026-06-25 23:05:11 +08:00
Codex 59c4ef330e docs(M08-A): 记录权益余额抵扣提交状态 2026-06-25 22:41:24 +08:00
Codex dda2b3cbc6 feat(M08-A): 接入权益和余额下单抵扣 2026-06-25 22:40:50 +08:00
Codex 3d885852cd docs(M08-A): 更新充值微信支付进度 2026-06-25 12:41:40 +08:00
Codex 53f5d6776f feat(M08-A): 接入充值微信支付调起 2026-06-25 12:40:21 +08:00
Codex 2bbb16e6f0 docs(M08-A): 记录顾客端权益验收 2026-06-25 12:08:16 +08:00
Codex e24920c9cc feat(M08-A): 接入顾客端权益明细 2026-06-25 12:07:09 +08:00
Codex fb1b593b24 docs(M08-A): 记录顾客端充值入口验收 2026-06-25 11:52:08 +08:00
Codex dbdfe3dfca feat(M08-A): 接入顾客端充值入口 2026-06-25 11:51:02 +08:00
Codex cb1109f387 docs(M08-A): 记录顾客端个人中心验收 2026-06-25 11:44:48 +08:00
Codex 7446852cca feat(M08-A): 接入顾客端个人中心 2026-06-25 11:43:25 +08:00
Codex c7c869c18b docs(M08-A): 记录顾客端续费换房分享验收 2026-06-25 11:35:40 +08:00
Codex 874b9b8e29 feat(M08-A): 接入顾客端续费换房分享 2026-06-25 11:34:23 +08:00
Codex a4e13d911e docs(M08-A): 记录订单开门阶段验收 2026-06-24 22:39:50 +08:00
Codex eaa86547c2 feat(M08-A): 接入顾客端订单开门 2026-06-24 22:39:15 +08:00
Codex d2c73a2c28 docs(M08-A): 记录订单查询阶段验收 2026-06-24 21:42:40 +08:00
Codex eea8eea12d feat(M08-A): 接入顾客端订单查询 2026-06-24 21:42:02 +08:00
Codex 47ec7fc30e docs(M08-A): 记录顾客端阶段验收 2026-06-24 21:27:25 +08:00
Codex 4ac2c960e4 feat(M08-A): 接入顾客端选店下单入口 2026-06-24 21:26:32 +08:00
133 changed files with 19008 additions and 305 deletions
+13 -6
View File
@@ -1,4 +1,4 @@
# 自助棋牌室系统
# 自助棋牌室系统
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区。Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.4.md`](./V5.4.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。
@@ -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`
- 最近工程提交:`ed0d455`,完成会员聚合查询服务和后台会员 API,支持订单、消费、余额、充值、优惠券、套餐和最近流水汇总
- 下一工程目标: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,按当前进度继续开发。
```
+1
View File
@@ -0,0 +1 @@
VITE_ADMIN_API_BASE_URL=https://api.txyundm.cn/admin-api
-1
View File
@@ -1 +0,0 @@
+12
View File
@@ -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>
+1582
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+667
View File
@@ -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>
+339
View File
@@ -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)
}
);
}
+310
View File
@@ -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>
+837
View File
@@ -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>
+19
View File
@@ -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 : '-';
}
+7
View File
@@ -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');
+995
View File
@@ -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;
}
}
+229
View File
@@ -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[];
}
+20
View File
@@ -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"]
}
+27
View File
@@ -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
}
}
}
});
+1 -1
View File
@@ -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-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/marketing-benefit-service.test.mjs && node tests/member-profile-service.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",
+17
View File
@@ -33,6 +33,9 @@ import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricin
import {
registerOrderStateRoutes, type OrderStateRouteOptions
} from './routes/order-state.js';
import {
registerOrderQueryRoutes, type OrderQueryRouteOptions
} from './routes/order-query.js';
import {
registerOrderManagementRoutes, type OrderManagementRouteOptions
} from './routes/order-management.js';
@@ -46,6 +49,8 @@ import {
registerDeviceControlRoutes, type DeviceControlRouteOptions
} 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;
@@ -58,6 +63,7 @@ export interface BuildAppOptions {
storeAccess?: StoreAccessRouteOptions;
pricing?: PricingRouteOptions;
orderState?: OrderStateRouteOptions;
orderQuery?: OrderQueryRouteOptions;
orderManagement?: OrderManagementRouteOptions;
orderShare?: OrderShareRouteOptions;
payment?: PaymentRouteOptions;
@@ -66,6 +72,8 @@ export interface BuildAppOptions {
devices?: DeviceRouteOptions;
deviceControl?: DeviceControlRouteOptions;
members?: MemberRouteOptions;
recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
}
declare module 'fastify' {
@@ -137,6 +145,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.orderState) {
await registerOrderStateRoutes(app, options.orderState);
}
if (options.orderQuery) {
await registerOrderQueryRoutes(app, options.orderQuery);
}
if (options.orderManagement) {
await registerOrderManagementRoutes(app, options.orderManagement);
}
@@ -158,6 +169,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.members) {
await registerMemberRoutes(app, options.members);
}
if (options.recharge) {
await registerRechargeRoutes(app, options.recharge);
}
if (options.cleaning) {
await registerCleaningRoutes(app, options.cleaning);
}
return app;
}
+9 -2
View File
@@ -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]
);
+23 -1
View File
@@ -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
+4 -1
View File
@@ -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
+26 -3
View File
@@ -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(
@@ -0,0 +1,39 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export class CustomerDeviceAccessError extends Error {
constructor(public readonly code: string) { super(code); }
}
interface OrderDeviceRow extends RowDataPacket {
orderId: string;
storeId: string;
roomId: string;
status: string;
}
export class CustomerDeviceAccessRepository {
constructor(private readonly pool: MySqlPool) {}
async getDoorContext(input: { tenantId: string; userId: string; orderId: string }) {
const [rows] = await this.pool.execute<OrderDeviceRow[]>(
`SELECT o.id AS orderId, o.store_id AS storeId, o.room_id AS roomId, o.status
FROM qipai_orders o
INNER JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id
AND a.user_id = ? AND a.revoked_at IS NULL
WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL
AND o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS')
AND UTC_TIMESTAMP(3) BETWEEN DATE_SUB(o.start_at, INTERVAL 30 MINUTE) AND o.end_at
LIMIT 1`,
[input.userId, input.tenantId, input.orderId]
);
if (!rows[0]) throw new CustomerDeviceAccessError('ORDER_DOOR_ACCESS_FORBIDDEN');
return {
orderId: String(rows[0].orderId),
storeId: String(rows[0].storeId),
roomId: String(rows[0].roomId),
status: rows[0].status
};
}
}
@@ -83,6 +83,14 @@ export class OrderManagementRepository {
});
}
async customerRenew(actor: OrderActor, orderId: string, input: {
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
}) {
return this.renewForActor(actor, orderId, input, async (connection, order) => {
await this.assertOwner(connection, actor.tenantId, order.id, actor.userId);
});
}
async changeRoom(actor: OrderActor, orderId: string, input: {
roomId: string; reason: string;
}) {
@@ -124,6 +132,17 @@ export class OrderManagementRepository {
});
}
async customerChangeRoom(actor: OrderActor, orderId: string, input: {
roomId: string; reason: string;
}) {
return this.changeRoomForActor(actor, orderId, input, async (connection, order, target) => {
await this.assertOwner(connection, actor.tenantId, order.id, actor.userId);
if (target.storeId !== order.storeId) {
throw new OrderManagementError('ORDER_ROOM_CHANGE_STORE_INVALID');
}
});
}
async adjustTime(actor: OrderActor, orderId: string, input: {
startAt?: Date; endAt?: Date; reason: string;
}) {
@@ -220,6 +239,93 @@ export class OrderManagementRepository {
};
}
private async renewForActor(
actor: OrderActor, orderId: string, input: {
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
},
authorize: (connection: PoolConnection, order: OrderRow) => Promise<void>
) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId);
await authorize(connection, order);
const duplicate = await this.duplicate(connection, actor, orderId);
if (duplicate) return this.duplicateResult(orderId, duplicate);
if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID');
}
if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID');
await this.lockRooms(connection, actor.tenantId, [order.roomId]);
await this.assertAvailable(
connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId
);
const unitPrice = await this.resolveUnitPrice(
connection, actor.tenantId, order, input.pricingPolicy
);
const amountDeltaCents = Math.ceil(
(input.endAt.getTime() - order.endAt.getTime()) / 3600000
) * unitPrice;
await connection.execute(
`UPDATE qipai_orders
SET end_at = ?, total_amount_cents = total_amount_cents + ?,
adjustment_amount_cents = adjustment_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET ends_at = ?, expires_at = GREATEST(expires_at, ?)
WHERE tenant_id = ? AND order_id = ?`,
[input.endAt, input.endAt, actor.tenantId, orderId]
);
return this.record(connection, actor, order, 'RENEW', input.reason, {
endAt: order.endAt
}, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents);
});
}
private async changeRoomForActor(
actor: OrderActor, orderId: string, input: { roomId: string; reason: string },
authorize: (
connection: PoolConnection, order: OrderRow, target: RoomRow
) => Promise<void>
) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId);
const duplicate = await this.duplicate(connection, actor, orderId);
if (duplicate) return this.duplicateResult(orderId, duplicate);
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID');
}
if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED');
await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]);
const target = await this.loadRoom(connection, actor.tenantId, input.roomId);
await authorize(connection, order, target);
await this.assertAvailable(
connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId
);
const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId);
const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000);
const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents);
await connection.execute(
`UPDATE qipai_orders
SET store_id = ?, room_id = ?,
total_amount_cents = GREATEST(0, total_amount_cents + ?),
adjustment_amount_cents = adjustment_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[target.storeId, target.id, amountDeltaCents, amountDeltaCents,
actor.tenantId, orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations SET room_id = ?
WHERE tenant_id = ? AND order_id = ?`,
[target.id, actor.tenantId, orderId]
);
return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, {
storeId: order.storeId, roomId: order.roomId
}, { storeId: target.storeId, roomId: target.id }, amountDeltaCents);
});
}
private async loadOrder(connection: PoolConnection, tenantId: string, orderId: string) {
const [rows] = await connection.execute<OrderRow[]>(
`SELECT o.id, o.store_id AS storeId, o.room_id AS roomId, o.status,
@@ -0,0 +1,163 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { OrderStatus } from './order-state-repository.js';
export class OrderQueryError extends Error {
constructor(public readonly code: string) { super(code); }
}
export interface OrderQueryAccess {
tenantId: string;
userId: string;
access: AccessProfile;
}
interface OrderListRow extends RowDataPacket {
id: string;
orderNo: string;
storeId: string;
storeName: string;
roomId: string;
roomName: string;
roomNo: string;
status: OrderStatus;
startAt: Date;
endAt: Date;
totalAmountCents: number;
paidAmountCents: number;
latestPaymentId: string | null;
latestPaymentProvider: string | null;
latestPaymentStatus: string | null;
createdAt: Date;
}
interface CountRow extends RowDataPacket { total: number }
export class OrderQueryRepository {
constructor(private readonly pool: MySqlPool) {}
async listMine(input: OrderQueryAccess & {
page: number;
pageSize: number;
status?: OrderStatus;
}) {
const where = [
'o.tenant_id = ?',
'o.deleted_at IS NULL',
'(a.user_id = ? OR ' + managerScopeSql(input.access, 'o.store_id') + ')'
];
const params: Array<string | number> = [input.tenantId, input.userId];
if (input.status) {
where.push('o.status = ?');
params.push(input.status);
}
const whereSql = where.join(' AND ');
const offset = (input.page - 1) * input.pageSize;
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(DISTINCT o.id) AS total
FROM qipai_orders o
LEFT JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<OrderListRow[]>(
`SELECT DISTINCT o.id, o.order_no AS orderNo, o.store_id AS storeId, s.name AS storeName,
o.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
o.status, o.start_at AS startAt, o.end_at AS endAt,
o.total_amount_cents AS totalAmountCents,
o.paid_amount_cents AS paidAmountCents, p.id AS latestPaymentId,
p.provider AS latestPaymentProvider, p.status AS latestPaymentStatus,
o.created_at AS createdAt
FROM qipai_orders o
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
INNER JOIN qipai_rooms r ON r.tenant_id = o.tenant_id AND r.id = o.room_id
LEFT JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
LEFT JOIN qipai_payments p
ON p.tenant_id = o.tenant_id AND p.order_id = o.id
AND p.id = (
SELECT MAX(p2.id) FROM qipai_payments p2
WHERE p2.tenant_id = o.tenant_id AND p2.order_id = o.id
AND p2.deleted_at IS NULL
)
WHERE ${whereSql}
ORDER BY o.created_at DESC, o.id DESC
LIMIT ? OFFSET ?`,
[...params, input.pageSize, offset]
);
return {
items: rows.map(publicOrder),
total: Number(counts[0]?.total ?? 0),
page: input.page,
pageSize: input.pageSize
};
}
async getMine(input: OrderQueryAccess & { orderId: string }) {
const result = await this.listMine({ ...input, page: 1, pageSize: 1 });
const order = result.items.find((item) => item.id === input.orderId);
if (order) return order;
const [rows] = await this.pool.execute<OrderListRow[]>(
`SELECT o.id, o.order_no AS orderNo, o.store_id AS storeId, s.name AS storeName,
o.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
o.status, o.start_at AS startAt, o.end_at AS endAt,
o.total_amount_cents AS totalAmountCents,
o.paid_amount_cents AS paidAmountCents, p.id AS latestPaymentId,
p.provider AS latestPaymentProvider, p.status AS latestPaymentStatus,
o.created_at AS createdAt
FROM qipai_orders o
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
INNER JOIN qipai_rooms r ON r.tenant_id = o.tenant_id AND r.id = o.room_id
LEFT JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
LEFT JOIN qipai_payments p
ON p.tenant_id = o.tenant_id AND p.order_id = o.id
AND p.id = (
SELECT MAX(p2.id) FROM qipai_payments p2
WHERE p2.tenant_id = o.tenant_id AND p2.order_id = o.id
AND p2.deleted_at IS NULL
)
WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL
AND (a.user_id = ? OR ${managerScopeSql(input.access, 'o.store_id')})
LIMIT 1`,
[input.tenantId, input.orderId, input.userId]
);
if (!rows[0]) throw new OrderQueryError('ORDER_NOT_FOUND');
return publicOrder(rows[0]);
}
}
function publicOrder(row: OrderListRow) {
return {
id: String(row.id),
orderNo: row.orderNo,
storeId: String(row.storeId),
storeName: row.storeName,
roomId: String(row.roomId),
roomName: row.roomName,
roomNo: row.roomNo,
status: row.status,
startAt: row.startAt,
endAt: row.endAt,
totalAmountCents: row.totalAmountCents,
paidAmountCents: row.paidAmountCents,
latestPayment: row.latestPaymentId === null ? null : {
id: String(row.latestPaymentId),
provider: row.latestPaymentProvider,
status: row.latestPaymentStatus
},
createdAt: row.createdAt
};
}
function managerScopeSql(access: AccessProfile, storeExpression: string) {
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
return '1 = 1';
}
if (!access.capabilities.includes('store.operation.read') || access.storeIds.length === 0) {
return '1 = 0';
}
return `${storeExpression} IN (${access.storeIds.map((id) => Number(id)).join(',')})`;
}
+34 -1
View File
@@ -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}`
});
}
}
}
}
+63 -4
View File
@@ -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'
+109 -41
View File
@@ -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 {
+76 -1
View File
@@ -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')
};
});
}
+22 -38
View File
@@ -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
) {
+707
View File
@@ -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);
};
+73
View File
@@ -3,6 +3,10 @@ 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 {
CustomerDeviceAccessError,
type CustomerDeviceAccessRepository
} from '../devices/customer-device-access-repository.js';
import {
DeviceControlError,
type CommandContext,
@@ -75,6 +79,10 @@ const socketTaskSchema = contextSchema.extend({
const socketClearTaskSchema = contextSchema.extend({
taskNum: z.number().int().min(0).max(20)
});
const orderParams = z.object({ orderId: id });
const customerOpenDoorSchema = z.object({
delayTime: z.number().int().min(1).max(14).default(4)
}).strict();
export interface DeviceControlRouteOptions {
service: Pick<DeviceControlService,
@@ -82,6 +90,7 @@ export interface DeviceControlRouteOptions {
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
| 'clearSmartSocketTask'>;
customerAccess?: Pick<CustomerDeviceAccessRepository, 'getDoorContext'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -119,6 +128,70 @@ export async function registerDeviceControlRoutes(
register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema,
(context, body) => options.service.clearSmartSocketTask(context, body.taskNum));
app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
if (!options.customerAccess) {
return reply.status(404).send({
code: 'CUSTOMER_DEVICE_CONTROL_NOT_AVAILABLE',
message: 'Customer device control is not available.',
traceId: request.traceId
});
}
const params = orderParams.safeParse(request.params);
const body = customerOpenDoorSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
traceId: request.traceId
});
}
try {
const order = await options.customerAccess.getDoorContext({
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
orderId: params.data.orderId
});
const context: CommandContext = {
tenantId: auth.session.tenantId,
storeId: order.storeId,
roomId: order.roomId,
orderId: order.orderId,
traceId: request.traceId,
access: {
roles: ['CUSTOMER'],
capabilities: ['device.write'],
storeIds: [order.storeId]
},
expiresAt: new Date(Date.now() + 30000)
};
return {
code: 0,
data: await options.service.controlDoor(context, {
order: 'open',
holdopen: 0,
delayTime: body.data.delayTime
}),
traceId: request.traceId
};
} catch (error) {
if (error instanceof CustomerDeviceAccessError) {
return reply.status(403).send({
code: error.code,
message: 'The order does not allow door access.',
traceId: request.traceId
});
}
if (!(error instanceof DeviceControlError)) throw error;
const status = error.code === 'DEVICE_OFFLINE' ? 409 : 400;
return reply.status(status).send({
code: error.code, message: 'Device control was rejected.', traceId: request.traceId
});
}
});
function register<T extends z.ZodTypeAny>(
url: string,
schema: T,
+57 -1
View File
@@ -18,7 +18,7 @@ const listSchema = z.object({
});
export interface MemberRouteOptions {
service: Pick<MemberProfileService, 'listMembers' | 'getMember'>;
service: Pick<MemberProfileService, 'listMembers' | 'getMember' | 'getMyProfile' | 'getMyBenefits'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -28,6 +28,32 @@ export async function registerMemberRoutes(
app: FastifyInstance,
options: MemberRouteOptions
): Promise<void> {
app.get('/app-api/profile', async (request, reply) => {
const auth = await requireProfileReader(request, reply, options);
if (!auth) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMyProfile({
tenantId: auth.tenantId,
userId: auth.userId
}),
traceId: request.traceId
}));
});
app.get('/app-api/profile/benefits', async (request, reply) => {
const auth = await requireProfileReader(request, reply, options);
if (!auth) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMyBenefits({
tenantId: auth.tenantId,
userId: auth.userId
}),
traceId: request.traceId
}));
});
app.get('/admin-api/members', async (request, reply) => {
const actor = await requireReader(request, reply, options);
if (!actor) return;
@@ -53,6 +79,36 @@ export async function registerMemberRoutes(
});
}
async function requireProfileReader(
request: FastifyRequest,
reply: FastifyReply,
options: MemberRouteOptions
): Promise<{ tenantId: string; userId: string } | 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
);
if (!access.capabilities.includes('profile.read')) {
reply.status(403).send({
code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.',
traceId: request.traceId
});
return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id };
}
async function requireReader(
request: FastifyRequest,
reply: FastifyReply,
+42 -2
View File
@@ -1,4 +1,4 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
@@ -27,7 +27,8 @@ const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict();
export interface OrderManagementRouteOptions {
repository: Pick<OrderManagementRepository,
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'>;
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'
| 'customerRenew' | 'customerChangeRoom'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -36,6 +37,34 @@ export interface OrderManagementRouteOptions {
export async function registerOrderManagementRoutes(
app: FastifyInstance, options: OrderManagementRouteOptions
) {
app.post('/app-api/orders/:orderId/renew', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
const body = renewSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
const actor = customerActor(auth, request);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.customerRenew(actor, params.data.orderId, body.data),
traceId: request.traceId
}));
});
app.post('/app-api/orders/:orderId/change-room', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
const body = roomSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
const actor = customerActor(auth, request);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.customerChangeRoom(actor, params.data.orderId, body.data),
traceId: request.traceId
}));
});
app.get('/app-api/orders/:orderId/cancellation-quote', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
@@ -82,6 +111,17 @@ export async function registerOrderManagementRoutes(
}
}
function customerActor(
auth: { tenantId: string; userId: string; access: AccessProfile },
request: FastifyRequest
): OrderActor {
return {
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
source: 'APP', traceId: request.traceId, ip: request.ip,
userAgent: String(request.headers['user-agent'] ?? ''), access: auth.access
};
}
async function authenticate(
authorization: string | undefined, options: OrderManagementRouteOptions
) {
+103
View File
@@ -0,0 +1,103 @@
import type { FastifyInstance, FastifyReply } 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 {
OrderQueryError,
type OrderQueryRepository
} from '../orders/order-query-repository.js';
import { type OrderStatus } from '../orders/order-state-repository.js';
const orderStatuses = [
'DRAFT', 'PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS',
'FINISHED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'CLOSED'
] as const satisfies readonly OrderStatus[];
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(orderStatuses).optional()
});
const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
export interface OrderQueryRouteOptions {
repository: Pick<OrderQueryRepository, 'listMine' | 'getMine'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerOrderQueryRoutes(
app: FastifyInstance,
options: OrderQueryRouteOptions
) {
app.get('/app-api/orders', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const query = listSchema.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return {
code: 0,
data: await options.repository.listMine({ ...auth, ...query.data }),
traceId: request.traceId
};
});
app.get('/app-api/orders/:orderId', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.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.repository.getMine({ ...auth, orderId: params.data.orderId }),
traceId: request.traceId
}));
});
}
async function authenticate(
authorization: string | undefined,
options: OrderQueryRouteOptions
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
const tenantId = result.session.tenantId;
const userId = result.session.user.id;
return {
tenantId,
userId,
access: await options.accessControl.getAccessProfile(tenantId, userId)
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof OrderQueryError)) throw error;
return reply.status(404).send({
code: error.code,
message: 'The requested order is not available.',
traceId
});
}
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_ORDER_QUERY',
message: 'The order query is invalid.',
traceId
});
}
+8 -1
View File
@@ -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}$/)
+188
View File
@@ -0,0 +1,188 @@
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()
});
const createSchema = z.object({
planId: z.string().regex(/^[1-9]\d{0,19}$/),
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' | 'createWechatPrepay' | 'processWechatNotification'
>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerRechargeRoutes(
app: FastifyInstance,
options: RechargeRouteOptions
) {
app.get('/app-api/recharge/plans', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const query = listSchema.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listAvailablePlans({
tenantId: auth.tenantId,
storeId: query.data.storeId ?? null
}),
traceId: request.traceId
}));
});
app.post('/app-api/recharge/orders', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = createSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.service.createRechargeOrder({
tenantId: auth.tenantId,
userId: auth.userId,
planId: body.data.planId,
storeId: body.data.storeId ?? null,
clientRequestId: body.data.clientRequestId,
traceId: request.traceId
}),
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(
authorization: string | undefined,
options: RechargeRouteOptions
) {
const result = await authenticateAccessToken(
authorization,
options.authRepository,
options.jwtSecret
);
if (!result) return null;
const access = await options.accessControl.getAccessProfile(
result.session.tenantId,
result.session.user.id
);
if (!access.capabilities.includes('profile.read')) return null;
return {
tenantId: result.session.tenantId,
platformAppId: result.session.platformAppId,
userId: result.session.user.id
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (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
: error.code.includes('FORBIDDEN') ? 403 : 400;
return reply.status(status).send({
code: error.code,
message: 'The recharge request is not available.',
traceId
});
}
}
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',
message: 'Authentication required.',
traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_RECHARGE_REQUEST',
message: 'The recharge request is invalid.',
traceId
});
}
+87 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import type { AuthRepository, LoginContext } from '../auth/auth-repository.js';
import type { StoreDiscoveryRepository } from '../stores/store-discovery-repository.js';
const headersSchema = z.object({
@@ -21,7 +21,7 @@ const querySchema = z.object({
});
export interface StoreDiscoveryRouteOptions {
repository: Pick<StoreDiscoveryRepository, 'findStores'>;
repository: Pick<StoreDiscoveryRepository, 'findStores' | 'getStore' | 'listRooms'>;
tenancy: Pick<AuthRepository, 'resolveLoginContext'>;
}
@@ -64,4 +64,89 @@ export async function registerStoreDiscoveryRoutes(
throw error;
}
});
app.get('/app-api/stores/:storeId', async (request, reply) => {
const context = await resolveContext(request, reply, options);
const params = storeIdSchema.safeParse(request.params);
if (!context) return;
if (!params.success) return invalid(reply, request.traceId);
const store = await options.repository.getStore({
tenantId: context.tenantId,
storeId: params.data.storeId
});
if (!store) {
return reply.status(404).send({
code: 'STORE_NOT_FOUND',
message: 'Store was not found.',
traceId: request.traceId
});
}
return { code: 0, data: store, traceId: request.traceId };
});
app.get('/app-api/stores/:storeId/rooms', async (request, reply) => {
const context = await resolveContext(request, reply, options);
const params = storeIdSchema.safeParse(request.params);
if (!context) return;
if (!params.success) return invalid(reply, request.traceId);
return {
code: 0,
data: await options.repository.listRooms({
tenantId: context.tenantId,
storeId: params.data.storeId
}),
traceId: request.traceId
};
});
}
const storeIdSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/)
});
async function resolveContext(
request: { headers: unknown; traceId: string },
reply: { status(code: number): { send(payload: unknown): unknown } },
options: StoreDiscoveryRouteOptions
): Promise<LoginContext | null> {
const headers = headersSchema.safeParse(request.headers);
if (!headers.success) {
invalid(reply, request.traceId);
return null;
}
try {
const context = await options.tenancy.resolveLoginContext(
headers.data['x-wechat-appid'], headers.data['tenant-id']
);
if (!context) {
reply.status(404).send({
code: 'APP_TENANT_NOT_FOUND',
message: 'Application tenant binding was not found.',
traceId: request.traceId
});
return null;
}
return context;
} catch (error) {
if (error instanceof Error && error.message === 'TENANT_SELECTION_REQUIRED') {
reply.status(409).send({
code: 'TENANT_SELECTION_REQUIRED',
message: 'tenant-id is required for an application bound to multiple tenants.',
traceId: request.traceId
});
return null;
}
throw error;
}
}
function invalid(
reply: { status(code: number): { send(payload: unknown): unknown } },
traceId: string
) {
return reply.status(400).send({
code: 'INVALID_STORE_DISCOVERY_REQUEST',
message: 'AppID and valid store filters are required.',
traceId
});
}
+1
View File
@@ -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()
});
+52 -3
View File
@@ -16,6 +16,7 @@ import { PricingRepository } from './orders/pricing-repository.js';
import { OrderStateRepository } from './orders/order-state-repository.js';
import { OrderManagementRepository } from './orders/order-management-repository.js';
import { OrderShareRepository } from './orders/order-share-repository.js';
import { OrderQueryRepository } from './orders/order-query-repository.js';
import { PaymentRepository } from './payments/payment-repository.js';
import {
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
@@ -28,18 +29,35 @@ import {
import { ThirdPartyService } from './third-party/third-party-service.js';
import { MqttService } from './mqtt/mqtt-service.js';
import { DeviceRepository } from './devices/device-repository.js';
import { CustomerDeviceAccessRepository } from './devices/customer-device-access-repository.js';
import { IotMessageService } from './devices/iot-message-service.js';
import { DeviceCommandService } from './devices/device-command-service.js';
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) =>
@@ -88,18 +106,24 @@ 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,
cancellationPolicy: orderManagementRepository
},
orderQuery: {
repository: new OrderQueryRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
orderManagement: {
repository: orderManagementRepository,
authRepository,
@@ -150,6 +174,31 @@ const app = await buildApp({
},
deviceControl: {
service: new DeviceControlService(pool, deviceCommands),
customerAccess: new CustomerDeviceAccessRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
members: {
service: new MemberProfileService(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
recharge: {
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
@@ -30,6 +30,30 @@ interface DiscoveryRow extends RowDataPacket {
sortOrder: number;
}
interface RoomRow extends RowDataPacket {
id: string;
storeId: string;
categoryName: string;
name: string;
roomNo: string;
capacity: number;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
maxAdvanceDays: number;
configurationStatus: string;
operationalStatus: string;
tags: string | string[] | null;
images: string | string[] | null;
sortOrder: number;
}
export interface DiscoveredStore {
id: string;
name: string;
@@ -46,6 +70,30 @@ export interface DiscoveredStore {
sortOrder: number;
}
export interface PublicRoom {
id: string;
storeId: string;
categoryName: string;
name: string;
roomNo: string;
capacity: number;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
maxAdvanceDays: number;
configurationStatus: string;
operationalStatus: string;
tags: string[];
images: string[];
sortOrder: number;
}
export class StoreDiscoveryRepository {
constructor(private readonly pool: MySqlPool) {}
@@ -125,6 +173,47 @@ export class StoreDiscoveryRepository {
return left.sortOrder - right.sortOrder || Number(left.id) - Number(right.id);
});
}
async getStore(query: { tenantId: string; storeId: string; now?: Date }):
Promise<DiscoveredStore | null> {
const stores = await this.findStores({ tenantId: query.tenantId, now: query.now });
return stores.find((store) => store.id === query.storeId) ?? null;
}
async listRooms(query: { tenantId: string; storeId: string }): Promise<PublicRoom[]> {
const [rows] = await this.pool.execute<RoomRow[]>(
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
r.name, r.room_no AS roomNo, r.capacity,
r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents,
r.holiday_price_cents AS holidayPriceCents,
r.overnight_price_cents AS overnightPriceCents,
r.full_day_price_cents AS fullDayPriceCents,
r.minimum_spend_cents AS minimumSpendCents,
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_start_minutes AS maxAdvanceStartMinutes,
r.max_advance_days AS maxAdvanceDays,
r.configuration_status AS configurationStatus,
r.operational_status AS operationalStatus, r.tags, r.images,
r.sort_order AS sortOrder
FROM qipai_rooms r
INNER JOIN qipai_stores s
ON s.tenant_id = r.tenant_id AND s.id = r.store_id AND s.deleted_at IS NULL
LEFT JOIN qipai_room_categories c
ON c.id = r.category_id AND c.tenant_id = r.tenant_id AND c.deleted_at IS NULL
WHERE r.tenant_id = ? AND r.store_id = ? AND r.deleted_at IS NULL
AND r.configuration_status = 'ENABLED'
ORDER BY r.sort_order, r.id`,
[query.tenantId, query.storeId]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: String(row.storeId),
tags: parseJsonArray(row.tags),
images: parseJsonArray(row.images)
}));
}
}
export function haversineMeters(
@@ -162,3 +251,14 @@ function isOpenAt(hours: DiscoveryRow[], timezone: string, now: Date): boolean {
}
return currentMinute >= today.openMinute || currentMinute < today.closeMinute;
}
function parseJsonArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value;
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : [];
} catch {
return [];
}
}
@@ -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(
@@ -55,6 +55,45 @@ interface LedgerRow extends RowDataPacket {
createdAt: Date;
}
interface CouponDetailRow extends RowDataPacket {
id: string;
templateId: string;
name: string;
status: string;
couponType: string;
discountAmountCents: number;
timeMinutes: number;
minOrderAmountCents: number;
remainingUses: number;
validFrom: Date;
validTo: Date;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
holidayOnly: number;
frozenOrderId: string | null;
usedAt: Date | null;
}
interface PackageDetailRow extends RowDataPacket {
id: string;
planId: string;
name: string;
status: string;
priceCents: number;
minutesTotal: number;
amountCentsTotal: number;
remainingMinutes: number;
remainingAmountCents: number;
validFrom: Date;
validTo: Date;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
holidayOnly: number;
frozenOrderId: string | null;
}
interface CountRow extends RowDataPacket { total: number }
export interface MemberActor {
@@ -137,6 +176,123 @@ export class MemberProfileService {
};
}
async getMyProfile(input: {
tenantId: string;
userId: string;
ledgerLimit?: number;
}) {
const [rows] = await this.pool.execute<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE u.tenant_id = ? AND u.id = ? AND u.user_type = 'CUSTOMER'
AND u.deleted_at IS NULL
LIMIT 1`,
[input.tenantId, input.userId]
);
if (!rows[0]) throw new MemberProfileError('MEMBER_NOT_FOUND');
const actor = {
tenantId: input.tenantId,
userId: input.userId,
access: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] }
};
return {
...await this.memberCard(actor, rows[0]),
recentLedger: await this.recentLedger(
input.tenantId,
String(rows[0].id),
input.ledgerLimit ?? 10
)
};
}
async getMyBenefits(input: {
tenantId: string;
userId: string;
limit?: number;
}) {
const limit = Math.max(1, Math.min(input.limit ?? 50, 100));
const [coupons] = await this.pool.execute<CouponDetailRow[]>(
`SELECT g.id, g.template_id AS templateId, t.name, g.status,
t.coupon_type AS couponType,
t.discount_amount_cents AS discountAmountCents,
t.time_minutes AS timeMinutes,
t.min_order_amount_cents AS minOrderAmountCents,
g.remaining_uses AS remainingUses,
g.valid_from AS validFrom, g.valid_to AS validTo,
t.store_id AS storeId, t.room_category_id AS roomCategoryId,
t.room_id AS roomId, t.holiday_only AS holidayOnly,
g.frozen_order_id AS frozenOrderId, g.used_at AS usedAt
FROM qipai_coupon_grants g
INNER JOIN qipai_coupon_templates t
ON t.tenant_id = g.tenant_id AND t.id = g.template_id
WHERE g.tenant_id = ? AND g.user_id = ? AND t.deleted_at IS NULL
ORDER BY FIELD(g.status, 'AVAILABLE', 'FROZEN', 'USED', 'EXPIRED'),
g.valid_to ASC, g.id DESC
LIMIT ?`,
[input.tenantId, input.userId, limit]
);
const [packages] = await this.pool.execute<PackageDetailRow[]>(
`SELECT h.id, h.plan_id AS planId, p.name, h.status,
p.price_cents AS priceCents,
p.minutes_total AS minutesTotal,
p.amount_cents_total AS amountCentsTotal,
h.remaining_minutes AS remainingMinutes,
h.remaining_amount_cents AS remainingAmountCents,
h.valid_from AS validFrom, h.valid_to AS validTo,
p.store_id AS storeId, p.room_category_id AS roomCategoryId,
p.room_id AS roomId, p.holiday_only AS holidayOnly,
h.frozen_order_id AS frozenOrderId
FROM qipai_package_holdings h
INNER JOIN qipai_package_plans p
ON p.tenant_id = h.tenant_id AND p.id = h.plan_id
WHERE h.tenant_id = ? AND h.user_id = ? AND p.deleted_at IS NULL
ORDER BY FIELD(h.status, 'ACTIVE', 'FROZEN', 'EXHAUSTED', 'EXPIRED'),
h.valid_to ASC, h.id DESC
LIMIT ?`,
[input.tenantId, input.userId, limit]
);
return {
coupons: coupons.map((row) => ({
couponGrantId: String(row.id),
templateId: String(row.templateId),
name: row.name,
status: row.status,
couponType: row.couponType,
discountAmountCents: Number(row.discountAmountCents),
timeMinutes: Number(row.timeMinutes),
minOrderAmountCents: Number(row.minOrderAmountCents),
remainingUses: Number(row.remainingUses),
validFrom: row.validFrom,
validTo: row.validTo,
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId),
holidayOnly: Number(row.holidayOnly) === 1,
frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId),
usedAt: row.usedAt
})),
packages: packages.map((row) => ({
packageHoldingId: String(row.id),
planId: String(row.planId),
name: row.name,
status: row.status,
priceCents: Number(row.priceCents),
minutesTotal: Number(row.minutesTotal),
amountCentsTotal: Number(row.amountCentsTotal),
remainingMinutes: Number(row.remainingMinutes),
remainingAmountCents: Number(row.remainingAmountCents),
validFrom: row.validFrom,
validTo: row.validTo,
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId),
holidayOnly: Number(row.holidayOnly) === 1,
frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId)
}))
};
}
private async memberCard(actor: MemberActor, row: MemberRow) {
const memberId = String(row.id);
const [walletRows] = await this.pool.execute<WalletSummaryRow[]>(
+329 -1
View File
@@ -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,9 +45,51 @@ 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: {
tenantId: string;
storeId?: string | null;
}) {
const params: Array<string | null> = [input.tenantId];
const storeFilter = input.storeId
? 'AND (store_id IS NULL OR store_id = ?)'
: '';
if (input.storeId) params.push(input.storeId);
const [rows] = await this.pool.execute<PlanRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, name,
pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents,
scope_type AS scopeType, starts_at AS startsAt, ends_at AS endsAt,
purchase_limit_per_user AS purchaseLimitPerUser, status
FROM qipai_recharge_plans
WHERE tenant_id = ? AND deleted_at IS NULL AND status = 'ACTIVE'
AND (starts_at IS NULL OR starts_at <= UTC_TIMESTAMP(3))
AND (ends_at IS NULL OR ends_at > UTC_TIMESTAMP(3))
${storeFilter}
ORDER BY pay_amount_cents ASC, id ASC`,
params
);
return rows.map((row) => ({
planId: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId),
name: row.name,
payAmountCents: Number(row.payAmountCents),
giftAmountCents: Number(row.giftAmountCents),
scopeType: row.scopeType,
purchaseLimitPerUser: row.purchaseLimitPerUser === null
? null : Number(row.purchaseLimitPerUser),
startsAt: row.startsAt,
endsAt: row.endsAt
}));
}
async createRechargeOrder(input: {
tenantId: string;
userId: string;
@@ -121,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,
@@ -180,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,
@@ -239,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;
}
+8 -1
View File
@@ -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.');
+712
View File
@@ -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
};
}
+31 -1
View File
@@ -107,6 +107,7 @@ const token = signAccessToken({
tid: '7', aid: '9', rv: 1
}, secret, 900);
let routed;
let routedContext;
const app = await buildApp({
deviceControl: {
jwtSecret: secret,
@@ -123,9 +124,19 @@ const app = await buildApp({
}
},
accessControl: { async getAccessProfile() { return managerAccess; } },
customerAccess: {
async getDoorContext(input) {
assert.deepEqual(input, { tenantId: '7', userId: '22', orderId: '31' });
return { orderId: '31', storeId: '11', roomId: '31', status: 'IN_PROGRESS' };
}
},
service: {
async controlPower(_context, input) { routed = input; return { status: 'PUBLISHED' }; },
async controlDoor() { return {}; },
async controlDoor(context, input) {
routedContext = context;
routed = input;
return { commandId: 'cmd-door', status: 'PUBLISHED' };
},
async playTts() { return {}; },
async stopTts() { return {}; },
async controlLed() { return {}; },
@@ -164,6 +175,25 @@ const invalid = await app.inject({
payload: { storeId: '11', roomId: '31', order: 'open', delayTime: 15 }
});
assert.equal(invalid.statusCode, 400);
const appOpenDoor = await app.inject({
method: 'POST',
url: '/app-api/orders/31/open-door',
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-open-door' },
payload: { delayTime: 4, holdopen: 1 }
});
assert.equal(appOpenDoor.statusCode, 400);
const appOpenDoorOk = await app.inject({
method: 'POST',
url: '/app-api/orders/31/open-door',
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-open-door' },
payload: { delayTime: 4 }
});
assert.equal(appOpenDoorOk.statusCode, 200);
assert.equal(routed.order, 'open');
assert.equal(routed.holdopen, 0);
assert.equal(routedContext.orderId, '31');
assert.equal(routedContext.traceId, 'm08a-open-door');
assert.deepEqual(routedContext.access.capabilities, ['device.write']);
await app.close();
console.log('PASS: M06-E device control commands include control-box, Sub-1G and smart socket.');
@@ -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,
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-member-profile-route-secret';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const forbiddenToken = signAccessToken({
sub: '22', sid: '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let profileInput;
let benefitsInput;
const app = await buildApp({
members: {
jwtSecret: secret,
authRepository: {
async validateSession(sessionId) {
return {
id: sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: sessionId === '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '22' : '21',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
}
},
accessControl: {
async getAccessProfile(tenantId, userId) {
return userId === '21'
? { roles: ['CUSTOMER'], capabilities: ['profile.read', 'order.self.read'], storeIds: [] }
: { roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: [] };
}
},
service: {
async listMembers() { throw new Error('not called'); },
async getMember() { throw new Error('not called'); },
async getMyBenefits(input) {
benefitsInput = input;
return {
coupons: [{
couponGrantId: '401',
name: '30元抵扣券',
status: 'AVAILABLE',
discountAmountCents: 3000
}],
packages: [{
packageHoldingId: '501',
name: '三小时畅玩包',
status: 'ACTIVE',
remainingMinutes: 120
}]
};
},
async getMyProfile(input) {
profileInput = input;
return {
memberId: input.userId,
nickname: 'Alice',
maskedPhone: '138****8000',
wallet: { cashBalanceCents: 1000, giftBalanceCents: 200, totalBalanceCents: 1200 },
benefits: { availableCoupons: 2, activePackages: 1, packageMinutes: 90 },
recentLedger: []
};
}
}
}
});
const profile = await app.inject({
method: 'GET',
url: '/app-api/profile',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(profile.statusCode, 200);
assert.equal(profileInput.tenantId, '7');
assert.equal(profileInput.userId, '21');
assert.equal(profile.json().data.wallet.totalBalanceCents, 1200);
const benefits = await app.inject({
method: 'GET',
url: '/app-api/profile/benefits',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(benefits.statusCode, 200);
assert.equal(benefitsInput.tenantId, '7');
assert.equal(benefitsInput.userId, '21');
assert.equal(benefits.json().data.coupons[0].couponGrantId, '401');
assert.equal(benefits.json().data.packages[0].packageHoldingId, '501');
const unauthorized = await app.inject({
method: 'GET',
url: '/app-api/profile'
});
assert.equal(unauthorized.statusCode, 401);
const forbidden = await app.inject({
method: 'GET',
url: '/app-api/profile',
headers: { authorization: `Bearer ${forbiddenToken}` }
});
assert.equal(forbidden.statusCode, 403);
await app.close();
console.log('PASS: M08-A customer profile route exposes only the current member.');
+150 -5
View File
@@ -47,25 +47,111 @@ const wallets = [
];
const coupons = [
{ tenantId: '7', userId: '21', status: 'AVAILABLE' },
{ tenantId: '7', userId: '21', status: 'FROZEN' },
{ tenantId: '7', userId: '22', status: 'USED' }
{
id: '401',
tenantId: '7',
userId: '21',
templateId: '301',
name: '30元抵扣券',
status: 'AVAILABLE',
couponType: 'FULL_REDUCTION',
discountAmountCents: 3000,
timeMinutes: 0,
minOrderAmountCents: 10000,
remainingUses: 1,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-01T00:00:00.000Z'),
storeId: '11',
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null,
usedAt: null
},
{
id: '402',
tenantId: '7',
userId: '21',
templateId: '302',
name: '60分钟券',
status: 'FROZEN',
couponType: 'TIME',
discountAmountCents: 0,
timeMinutes: 60,
minOrderAmountCents: 0,
remainingUses: 1,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-02T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: '301',
usedAt: null
},
{
id: '403',
tenantId: '7',
userId: '22',
templateId: '303',
name: '其他用户券',
status: 'USED',
couponType: 'FULL_REDUCTION',
discountAmountCents: 500,
timeMinutes: 0,
minOrderAmountCents: 0,
remainingUses: 0,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-07-01T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null,
usedAt: new Date('2026-06-20T00:00:00.000Z')
}
];
const packages = [
{
id: '501',
tenantId: '7',
userId: '21',
planId: '601',
name: '三小时畅玩包',
status: 'ACTIVE',
priceCents: 9000,
minutesTotal: 180,
amountCentsTotal: 3000,
remainingMinutes: 120,
remainingAmountCents: 3_000
remainingAmountCents: 3_000,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-08-01T00:00:00.000Z'),
storeId: '11',
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: null
},
{
id: '502',
tenantId: '7',
userId: '21',
planId: '602',
name: '锁定中的套餐',
status: 'FROZEN',
priceCents: 5000,
minutesTotal: 60,
amountCentsTotal: 2000,
remainingMinutes: 60,
remainingAmountCents: 2_000
remainingAmountCents: 2_000,
validFrom: new Date('2026-06-01T00:00:00.000Z'),
validTo: new Date('2026-08-02T00:00:00.000Z'),
storeId: null,
roomCategoryId: null,
roomId: null,
holidayOnly: 0,
frozenOrderId: '301'
}
];
@@ -185,6 +271,53 @@ function createService() {
.reduce((sum, item) => sum + item.remainingAmountCents, 0)
}], []];
}
if (sql.includes('FROM qipai_coupon_grants g')) {
const rows = coupons
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
.slice(0, Number(params[2]));
return [rows.map((item) => ({
id: item.id,
templateId: item.templateId,
name: item.name,
status: item.status,
couponType: item.couponType,
discountAmountCents: item.discountAmountCents,
timeMinutes: item.timeMinutes,
minOrderAmountCents: item.minOrderAmountCents,
remainingUses: item.remainingUses,
validFrom: item.validFrom,
validTo: item.validTo,
storeId: item.storeId,
roomCategoryId: item.roomCategoryId,
roomId: item.roomId,
holidayOnly: item.holidayOnly,
frozenOrderId: item.frozenOrderId,
usedAt: item.usedAt
})), []];
}
if (sql.includes('FROM qipai_package_holdings h')) {
const rows = packages
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
.slice(0, Number(params[2]));
return [rows.map((item) => ({
id: item.id,
planId: item.planId,
name: item.name,
status: item.status,
priceCents: item.priceCents,
minutesTotal: item.minutesTotal,
amountCentsTotal: item.amountCentsTotal,
remainingMinutes: item.remainingMinutes,
remainingAmountCents: item.remainingAmountCents,
validFrom: item.validFrom,
validTo: item.validTo,
storeId: item.storeId,
roomCategoryId: item.roomCategoryId,
roomId: item.roomId,
holidayOnly: item.holidayOnly,
frozenOrderId: item.frozenOrderId
})), []];
}
if (sql.includes('FROM qipai_recharge_orders')) {
const tenantId = String(params[0]);
const userId = String(params[1]);
@@ -306,6 +439,18 @@ const forbiddenReader = {
assert.equal(member.benefits.packageMinutes, 180);
}
{
const service = createService();
const benefits = await service.getMyBenefits({ tenantId: '7', userId: '21' });
assert.equal(benefits.coupons.length, 2);
assert.equal(benefits.coupons[0].couponGrantId, '401');
assert.equal(benefits.coupons[0].discountAmountCents, 3000);
assert.equal(benefits.coupons[1].frozenOrderId, '301');
assert.equal(benefits.packages.length, 2);
assert.equal(benefits.packages[0].packageHoldingId, '501');
assert.equal(benefits.packages[0].remainingAmountCents, 3000);
}
{
const service = createService();
const scoped = await service.listMembers({
+87 -1
View File
@@ -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.');
+6 -1
View File
@@ -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);
+40
View File
@@ -8,6 +8,7 @@ const token = signAccessToken({
tid: '7', aid: '9', rv: 1
}, secret, 900);
let called;
let customerCalled;
const authRepository = {
async validateSession() {
return {
@@ -33,7 +34,15 @@ const app = await buildApp({
called = { actor, orderId, body };
return { orderId, adjustmentType: 'RENEW', amountDeltaCents: 1200 };
},
async customerRenew(actor, orderId, body) {
customerCalled = { action: 'renew', actor, orderId, body };
return { orderId, adjustmentType: 'RENEW', amountDeltaCents: 1800 };
},
async changeRoom() { throw new Error('not called'); },
async customerChangeRoom(actor, orderId, body) {
customerCalled = { action: 'changeRoom', actor, orderId, body };
return { orderId, adjustmentType: 'CHANGE_ROOM', amountDeltaCents: -500 };
},
async adjustTime() { throw new Error('not called'); },
async note() { throw new Error('not called'); },
async cancellationQuote() {
@@ -71,6 +80,37 @@ assert.equal(called.orderId, '31');
assert.equal(called.actor.traceId, 'm04c-renew-route');
assert.equal(called.body.pricingPolicy, 'LOCKED');
const customerRenewed = await app.inject({
method: 'POST',
url: '/app-api/orders/31/renew',
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-customer-renew' },
payload: {
endAt: new Date(Date.now() + 10800000).toISOString(),
pricingPolicy: 'CURRENT',
reason: 'miniapp renew'
}
});
assert.equal(customerRenewed.statusCode, 200);
assert.equal(customerCalled.action, 'renew');
assert.equal(customerCalled.actor.source, 'APP');
assert.equal(customerCalled.actor.traceId, 'm08a-customer-renew');
assert.equal(customerCalled.body.pricingPolicy, 'CURRENT');
const customerChanged = await app.inject({
method: 'POST',
url: '/app-api/orders/31/change-room',
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-customer-change-room' },
payload: {
roomId: '32',
reason: 'miniapp change room'
}
});
assert.equal(customerChanged.statusCode, 200);
assert.equal(customerCalled.action, 'changeRoom');
assert.equal(customerCalled.actor.source, 'APP');
assert.equal(customerCalled.orderId, '31');
assert.equal(customerCalled.body.roomId, '32');
const quote = await app.inject({
method: 'GET',
url: '/app-api/orders/31/cancellation-quote',
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-order-query-jwt-secret-32';
const token = signAccessToken({
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7',
aid: '9',
rv: 1
}, secret, 900);
const authRepository = {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: '21',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
}
};
let listInput;
let detailInput;
const app = await buildApp({
orderQuery: {
jwtSecret: secret,
authRepository,
accessControl: {
async getAccessProfile() {
return { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
}
},
repository: {
async listMine(input) {
listInput = input;
return {
items: [{
id: '31',
orderNo: 'QP202606240001',
storeName: '近店',
roomName: '青竹房',
status: 'PENDING_PAYMENT',
totalAmountCents: 3600,
latestPayment: null
}],
total: 1,
page: input.page,
pageSize: input.pageSize
};
},
async getMine(input) {
detailInput = input;
return {
id: input.orderId,
orderNo: 'QP202606240001',
storeName: '近店',
roomName: '青竹房',
status: 'PENDING_PAYMENT',
totalAmountCents: 3600,
latestPayment: null
};
}
}
}
});
const listed = await app.inject({
method: 'GET',
url: '/app-api/orders?page=2&pageSize=5&status=PENDING_PAYMENT',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(listed.statusCode, 200);
assert.equal(listed.json().data.items[0].id, '31');
assert.equal(listInput.tenantId, '7');
assert.equal(listInput.userId, '21');
assert.equal(listInput.page, 2);
assert.equal(listInput.pageSize, 5);
assert.equal(listInput.status, 'PENDING_PAYMENT');
const detail = await app.inject({
method: 'GET',
url: '/app-api/orders/31',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(detail.statusCode, 200);
assert.equal(detailInput.orderId, '31');
const unauthenticated = await app.inject({
method: 'GET',
url: '/app-api/orders'
});
assert.equal(unauthenticated.statusCode, 401);
const invalid = await app.inject({
method: 'GET',
url: '/app-api/orders?pageSize=999',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(invalid.statusCode, 400);
await app.close();
console.log('PASS: M08-A customer order query routes expose scoped list and detail.');
+9
View File
@@ -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',
+14
View File
@@ -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',
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-recharge-route-secret-32';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let createdInput;
let listInput;
let prepayInput;
let notifyInput;
const app = await buildApp({
recharge: {
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: '21',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() {
return { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] };
}
},
service: {
async listAvailablePlans(input) {
listInput = input;
return [{
planId: '31',
storeId: '11',
name: 'charge 100 gift 20',
payAmountCents: 10000,
giftAmountCents: 2000,
scopeType: 'STORE'
}];
},
async createRechargeOrder(input) {
createdInput = input;
return {
rechargeOrderId: '901',
planId: input.planId,
rechargeNo: 'RCHTEST',
status: 'PENDING_PAYMENT',
payAmountCents: 10000,
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 };
}
}
}
});
const plans = await app.inject({
method: 'GET',
url: '/app-api/recharge/plans?storeId=11',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(plans.statusCode, 200);
assert.deepEqual(listInput, { tenantId: '7', storeId: '11' });
assert.equal(plans.json().data[0].payAmountCents, 10000);
const created = await app.inject({
method: 'POST',
url: '/app-api/recharge/orders',
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-recharge-order' },
payload: {
planId: '31',
storeId: '11',
clientRequestId: 'miniapp-recharge-001'
}
});
assert.equal(created.statusCode, 201);
assert.equal(createdInput.tenantId, '7');
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',
headers: { authorization: `Bearer ${token}` },
payload: { planId: 'bad' }
});
assert.equal(invalid.statusCode, 400);
await app.close();
console.log('PASS: M08-A recharge routes expose available plans and customer order creation.');
+150 -1
View File
@@ -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.');
+48 -1
View File
@@ -50,6 +50,37 @@ const app = await buildApp({
async findStores(query) {
assert.equal(query.city, '上海市');
return stores;
},
async getStore(query) {
assert.equal(query.tenantId, '7');
assert.equal(query.storeId, '11');
return stores[0];
},
async listRooms(query) {
assert.deepEqual(query, { tenantId: '7', storeId: '11' });
return [{
id: '101',
storeId: '11',
categoryName: '棋牌包间',
name: '青竹房',
roomNo: 'A101',
capacity: 6,
basePriceCents: 3600,
weekdayPriceCents: 3200,
holidayPriceCents: 4200,
overnightPriceCents: 12800,
fullDayPriceCents: 22800,
minimumSpendCents: 0,
depositCents: 5000,
minimumMinutes: 60,
maxAdvanceStartMinutes: 30,
maxAdvanceDays: 7,
configurationStatus: 'ENABLED',
operationalStatus: 'AVAILABLE',
tags: ['自动麻将机'],
images: [],
sortOrder: 1
}];
}
}
}
@@ -67,6 +98,22 @@ const invalid = await app.inject({
headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' }
});
assert.equal(invalid.statusCode, 400);
const detail = await app.inject({
method: 'GET',
url: '/app-api/stores/11',
headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' }
});
assert.equal(detail.statusCode, 200);
assert.equal(detail.json().data.name, '近店');
const rooms = await app.inject({
method: 'GET',
url: '/app-api/stores/11/rooms',
headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' }
});
assert.equal(rooms.statusCode, 200);
assert.equal(rooms.json().data[0].id, '101');
await app.close();
console.log('PASS: M03-C trusted distance sorting, city fallback and public store discovery are present.');
console.log('PASS: M08-A public store detail and room discovery support customer miniapp browsing.');
+36 -2
View File
@@ -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();
+35 -1
View File
@@ -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、备注和创建时间。
+5 -5
View File
@@ -1,8 +1,8 @@
# 当前开发成果基线
# 当前开发成果基线
> 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 DONEMySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D |
| 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE |
| 微信小程序 | Git 中仅有 `miniapp/.gitkeep`;工作区另有未跟踪模板,按 ISSUE-007 隔离 | Git 状态与当前文件扫描 | M08 未开始,模板未审查、未纳管,不能标记 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 PARTIALM08-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 DONEWSL 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`
+236
View File
@@ -0,0 +1,236 @@
# M08-A 顾客端
## 本次工程增量
- 后端新增顾客端公开门店详情和房间列表接口:`GET /app-api/stores/:storeId``GET /app-api/stores/:storeId/rooms`,继续通过 `x-wechat-appid` 与可选 `tenant-id` 解析租户,房间只返回未删除且启用的公开数据。
- 小程序新增登录态封装,所有需登录接口自动携带 `Authorization: Bearer <token>`;首页门店卡片可进入门店详情。
- 门店详情页加载门店资料、公开房间列表和受控 Wi-Fi;Wi-Fi 只在登录后请求 `/app-api/stores/:storeId/wifi`
- 房间详情页接入报价、预占、测试支付创建和测试支付回调完成,形成顾客端下单链路的本地可验证入口。
- 新增 `scripts/check-miniapp-m08-a.mjs`,检查小程序页面注册、固定生产 API 域名、登录和 M08-A 关键接口调用。
## 验收结果
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `npm run build``backend/`):PASS。
- `node tests/store-discovery.test.mjs`PASS。
- `node tests/pricing.test.mjs`PASS。
- `node tests/payment.test.mjs`PASS。
- `npm test``backend/`):PASS。
## 状态
M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单列表、开门、续费、取消、换房、分享、余额、优惠券、套餐、个人中心、生产微信支付调起和真机合法域名验证仍未完成,游标继续停留在 M08-A。
## 提交
- 工程提交:`4ac2c96`
- 远端校验:push 后执行 `HEAD == origin/main`
## 2026-06-24 续接:订单列表与详情
- 后端新增顾客端订单查询服务和路由:`GET /app-api/orders``GET /app-api/orders/:orderId`,按本人 `qipai_order_user_access` 与门店管理范围裁剪数据。
- 小程序新增 `pages/orders/list``pages/orders/detail`,可查看我的订单、订单详情、状态历史、取消测算和取消订单入口;房间预占成功后可直接进入订单详情。
- `scripts/check-miniapp-m08-a.mjs` 扩展检查订单页注册和订单相关真实 API 调用。
验收:
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `npm test``backend/`):PASS。
状态:
- M08-A 仍为 `PARTIAL`;开门、续费、换房、分享、余额、优惠券、套餐、个人中心、真实微信支付调起和真机合法域名验证待继续。
## 2026-06-24 续接:顾客端开门入口
- 后端新增顾客订单开门权限仓储,`GET/POST` 链路只允许本人未撤销订单、订单状态为 `PAID/RESERVED/IN_PROGRESS` 且处于开始前 30 分钟到结束时间之间的订单开门。
- 新增 `POST /app-api/orders/:orderId/open-door`,按订单上下文构造最小 `device.write` 权限并调用现有门控服务;顾客端强制 `holdopen=0`,不暴露后台设备控制接口。
- 小程序订单详情页新增“开门”按钮,调用订单绑定的 app-api 开门入口。
- `scripts/check-miniapp-m08-a.mjs` 扩展检查 `open-door` 调用。
验收:
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `node tests/device-control.test.mjs`PASS。
- `npm test``backend/`):PASS。
状态:
- M08-A 仍为 `PARTIAL`;续费、换房、分享、余额、优惠券、套餐、个人中心、真实微信支付调起和真机合法域名/实物开门验证待继续。
## 2026-06-25 续接:顾客端续费、换房与分享
- 后端新增顾客专用订单调整入口:`POST /app-api/orders/:orderId/renew``POST /app-api/orders/:orderId/change-room`
- 顾客续费只允许本人未撤销订单访问,继续复用服务端时段冲突、状态和价格计算;顾客换房额外限制目标房间必须属于原订单门店,避免跨门店改单。
- 小程序订单详情页新增续费分钟、换房目标房间 ID、分享有效期输入;续费/换房调用真实 app-api 并显示差价,分享调用 `POST /app-api/orders/:orderId/shares` 生成临时口令。
- `scripts/check-miniapp-m08-a.mjs` 扩展检查 `/renew``/change-room``/shares` 调用。
验收:
- `npm run build``backend/`):PASS。
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `node tests/order-management.test.mjs`PASS。
- `npm test``backend/`):PASS。
状态:
- M08-A 仍为 `PARTIAL`;余额、优惠券、套餐、个人中心、生产微信支付调起、真机合法域名和实物开门验证待继续。
提交:
- 工程提交:`874b9b8`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`
## 2026-06-25 续接:顾客端个人中心
- 后端新增 `MemberProfileService.getMyProfile`,按当前登录顾客读取本人资料、余额、优惠券/套餐汇总、充值消费摘要和最近钱包账单。
- 新增 `GET /app-api/profile`,校验登录态与 `profile.read` 权限,只返回当前用户本人数据;同时补上生产 `server.ts` 中的会员路由注册。
- 新增 `backend/tests/member-profile-route.test.mjs`,覆盖正常读取、未登录拒绝和缺少 `profile.read` 拒绝。
- 小程序新增 `pages/profile/index` 个人中心页,展示总余额、现金/赠送余额、可用券、套餐、套餐分钟、充值消费摘要和最近账单;首页新增个人中心入口。
- `scripts/check-miniapp-m08-a.mjs` 扩展检查个人中心页面注册、`/profile` 调用和余额/权益/账单绑定。
验收:
- `npm run build``backend/`):PASS。
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `node tests/member-profile-route.test.mjs`PASS。
- `npm test``backend/`):PASS。
状态:
- M08-A 仍为 `PARTIAL`;优惠券/套餐明细页、充值入口、生产微信支付调起、真机合法域名和实物开门验证待继续。
提交:
- 工程提交:`7446852`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`
## 2026-06-25 续接:顾客端充值入口
- `RechargeService` 新增可用充值计划查询,按租户、有效期、启用状态和可选门店过滤。
- 后端新增 `GET /app-api/recharge/plans``POST /app-api/recharge/orders`,顾客登录后可查看充值计划并创建幂等充值单。
- 生产 `server.ts` 接入 `RechargeService``WalletLedgerService`,充值路由随真实后端启动注册。
- 新增 `backend/tests/recharge-route.test.mjs`,覆盖充值计划查询、顾客充值单创建和非法请求拒绝。
- 小程序新增 `pages/recharge/index`,个人中心新增充值入口,可展示实付/赠送/到账金额并创建待支付充值单。
- `scripts/check-miniapp-m08-a.mjs` 扩展检查充值页面注册、`/recharge/plans``/recharge/orders` 调用。
验收:
- `npm run build``backend/`):PASS。
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `node tests/recharge-route.test.mjs`PASS。
- `npm test``backend/`):PASS。
状态:
- M08-A 仍为 `PARTIAL`;优惠券/套餐明细页、充值微信支付调起、订单生产微信支付调起、真机合法域名和实物开门验证待继续。
提交:
- 工程提交:`dbdfe3d`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`
## 2026-06-25 续接:顾客端权益明细与微信支付调起
- 后端 `MemberProfileService` 新增 `getMyBenefits`,按当前登录顾客读取本人优惠券和套餐持有明细,返回状态、金额、分钟、剩余次数、有效期、适用范围和冻结订单,不暴露其他用户权益。
- 新增 `GET /app-api/profile/benefits`,复用登录态和 `profile.read` 权限校验,只允许读取当前用户本人权益。
- `backend/tests/member-profile-service.test.mjs``backend/tests/member-profile-route.test.mjs` 扩展覆盖权益明细查询和本人鉴权。
- 小程序新增 `pages/benefits/index` 权益明细页,个人中心新增入口,可展示优惠券和套餐的可用/锁定/已用尽状态、金额、分钟、有效期和适用范围。
- 小程序 `utils/api.js` 新增 `requestWechatPayment`,房间下单页新增“微信支付”按钮:先创建 `WECHAT` 支付单,再调用 `/pay/wechat/prepay`,仅在后端返回真实预支付参数后调用 `wx.requestPayment`;测试支付路径保留。
- `scripts/check-miniapp-m08-a.mjs` 扩展检查权益页注册、`/profile/benefits``/pay/wechat/prepay``wx.requestPayment`
验收:
- `npm run build``backend/`):PASS。
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `node tests/member-profile-service.test.mjs`PASS。
- `node tests/member-profile-route.test.mjs`PASS。
- `node tests/marketing-benefit-service.test.mjs`PASS。
- `npm test``backend/`):PASS。
- `powershell -ExecutionPolicy Bypass -File scripts\dev\windows\test-all.ps1`PASS。
状态:
- 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`
+639
View File
@@ -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/`):PASS0 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`
+20 -12
View File
@@ -1,4 +1,4 @@
# 功能状态
# 功能状态
> 只能使用 TODO / DOING / PARTIAL / BLOCKED_INTERNAL / BLOCKED_EXTERNAL / DONE。
@@ -11,17 +11,21 @@
| API-001 | 固定 HTTPS API 域名 | M00-E/M01/M08/M10 | PARTIAL | `1192dcb` | 已新增 Windows 检查脚本、Ubuntu 菜单检查和固定域名 Nginx 模板;M01-A 健康接口、编译和本地 HTTP 检查通过;M01-B 的连接池、迁移 CLI、旧表兼容读取、整数分转换已通过契约测试,并在 WSL MySQL 8.4.9 的空库和脱敏旧库副本完成 `up/verify/down/up/verify`,异常退出清理后临时库与账号均为 0。 | DNS/HTTPS 生产验证、Ubuntu 24.04 生产运行时、业务接口和鉴权未验收。 | 进入 M02 身份与权限;后续在 M10 接入生产域名、证书和 Nginx 真实验收。 |
| ASYNC-001 | MySQL outbox 与轻量任务 worker | M01-C | DONE | `2b90d1f` | outbox/任务迁移、幂等键、`FOR UPDATE SKIP LOCKED` 领取、租约恢复、指数退避、人工补偿状态和 PM2 worker 已实现;Windows 契约测试与 WSL MySQL 8.4.9 往返迁移、重复入队、重启后领取验证通过。 | 具体通知、订单、设备、退款和统计 handler 在对应业务模块接入。 | M02 起按业务事务写入 outbox/任务并注册真实 handler。 |
| TLS-001 | Nginx 与证书自动化 | M00-E/M10 | PARTIAL | `4cb3ab6` | 已生成 Nginx 模板、Certbot 命令说明和菜单第 4 项检查,可检查模板、站点启用、TLS、健康端点、证书文件、续期配置和 `certbot.timer`。 | 证书申请/续期 dry-run、80/443 生产验证未执行。 | 在生产 Ubuntu 执行证书签发、续期 dry-run 和 Nginx 安装记录。 |
| WXNET-001 | 微信合法域名与真机验证 | M00-E/M08/M10 | TODO | - | 已补 API 域名报告入口,但未做微信后台或真机验证。 | 微信后台/真机未验证。 | 后续导入小程序后执行合法域名和真机验证。 |
| WXNET-001 | 微信合法域名与真机验证 | M00-E/M08/M10 | PARTIAL | `4ac2c96` | 小程序配置继续固定 `https://api.txyundm.cn/app-api``scripts/check-miniapp-m08-a.mjs` 已检查不存在 localhost、127.0.0.1 和 HTTP API。 | 微信后台合法域名和真机体验版未验证。 | M08-A 后续补真机登录、上传/下载、下单和设备接口冒烟;M10 做生产验收。 |
| OPS-001 | 固定 `/opt/apps` 目录 | M00-D/M10 | PARTIAL | `292ab7f` | `scripts/setup/init-layout.sh` 已生成目录布局、uploads 目录和 manifestWSL 检查脚本通过,未在生产 Ubuntu 执行。 | 生产操作未执行。 | 由管理员在 Ubuntu 菜单执行并记录结果。 |
| OPS-002 | 单仓库 Gitea 推送与拉取部署 | M00-B/M10 | PARTIAL | 远端 HEAD | Windows 到 Gitea `origin/main` 首次 push 与远端校验通过。 | 生产服务器只读拉取部署尚未验证。 | M00-D/M00-E 继续完善生产拉取、菜单部署和状态检查。 |
| 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 | `c658b09` | 微信原生基础模板、固定生产 API 配置和开发者工具项目配置已纳管;JSON 与 JavaScript 静态语法检查通过。 | 当前仅为基础模板,登录、门店、订单和多角色业务页面尚未接入。 | 保持 M03-C 当前游标;到 M08 基于现有模板实现完整业务,不重复初始化。 |
| 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 起业务资源继承门店范围。 |
@@ -29,28 +33,32 @@
| ROOM-001 | 房间配置 | M03-A/M04 | PARTIAL | `15139b3` | 已实现房型、整数分价格、工作日/节假日/通宵价、押金、最低时长、提前规则、禁用时段、标签、图片和软删除。 | 价格计算、节假日日历及下单时段冲突属于 M04。 | M04 接入定价快照和预约校验。 |
| ADV-001 | 广告管理 | M03-B/M08 | PARTIAL | `f746242` | 已实现平台、租户、门店三级广告,素材租户归属、投放时间、跳转、排序和范围权限均由后端校验。 | 后台编辑页面和小程序展示待 M08。 | M08 接入广告管理和前端投放。 |
| UI-001 | 门店装修和多套模板 | M03-B/M08 | PARTIAL | `f746242` | 已实现装修草稿版本、Schema 版本、组件白名单、发布和旧版本归档。 | 可视化编辑器及小程序模板渲染待 M08。 | M08 实现编辑器与渲染组件。 |
| MAP-001 | 地图选店和最近门店 | M03-C/M08 | PARTIAL | `df373fa` | 已实现 AppID/tenant 隔离门店发现、城市/营业筛选、按门店时区判断营业中、后端 Haversine 距离排序;小程序支持定位和手工城市降级。 | 地图 SDK 可视化和真机定位授权尚未验收。 | M08 接入地图界面并完成真机验证。 |
| 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 | PARTIAL | `91801fe` | 正式订单与分享令牌均执行最小权限;分享开门权限独立于查看和续费,撤销、过期或订单终态即时失效;MySQL 8.4.9 实测通过。 | 真实开门与设备权限仍需 M06 厂商适配器和硬件联调。 | M06 接入真实设备权限。 |
| ORD-001 | 预约下单与多方式支付 | M04/M05 | PARTIAL | `cda640b` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付:团购券核销与直订认领均生成统一支付记录并事务化推进订单;Windows 全量测试及 WSL MySQL 8.4.9 往返迁移通过。 | 缺真实微信和团购平台授权联调;余额/套餐支付属于 M07。 | M05-D 完成分账与收款配置;取得平台凭据后补真实联调。 |
| WAL-001 | 充值优惠 | M07-B | PARTIAL | `d9743d3` | 已建立充值规则和充值订单表,支持有效期、适用门店、限购、启停、clientRequestId 幂等;支付成功后调用钱包入账重复支付回调不重复充值;Windows 全量后端回归通过。 | 真实微信充值支付回调仍需 M05 生产凭据和 M08 前端入口联调。 | 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 | `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 | `d9743d3` | 账单底表已包含业务类型、业务号、现金/赠送变动、变动后余额、操作人、trace 和 metadata;M07-B 充值成功后写入真实充值流水。 | 查询 API 和前端账单页面待 M08;优惠券/套餐不直接写钱包流水。 | M08 增加分页查询。 |
| WAL-004 | 优惠券和套餐权益 | M07-C | PARTIAL | `4ae9296` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;Windows 全量后端回归通过。 | 前端发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M07-D 聚合会员券和套餐状态;M08 接入页面。 |
| MEM-001 | 会员管理 | M07-D/M08 | PARTIAL | `ed0d455` | 已实现后台会员聚合查询服务和 `/admin-api/members``/admin-api/members/:id`;可查看注册/最近登录、订单数、消费额、余额、充值、优惠券、套餐和最近钱包流水,手机号脱敏,租户/门店范围裁剪通过测试。 | 管理员赠券、余额人工调整、禁用、备注和前端页面需在 M08 管理端接入并复用既有审计能力。 | M08-A/C 接入小程序个人中心与后台会员管理页面。 |
| WAL-003 | 余额账单 | M07-A/M08 | PARTIAL | `7446852` | 账单底表已包含业务类型、业务号、现金/赠送变动、变动后余额、操作人、trace 和 metadata;M07-B 充值成功后写入真实充值流水;M08-A 个人中心已展示最近钱包账单。 | 完整分页账单页面待继续;优惠券/套餐不直接写钱包流水。 | 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 继续复用同一消费链路。 |
| IOT-007 | 命令状态机 | M01/M06-C | DONE | `e795cdb` | 已实现 `PENDING/PUBLISHED/ACKED/FAILED/TIMEOUT/UNKNOWN/CANCELLED` 数据模型,发布失败、ACK 结果、超时和 13 位命令 ID 均有测试;发布成功不等于 ACK。 | - | M06-D/F 接入业务权限、补偿与过期取消。 |
| DEV-001 | 一键开门与设备动作 | M06-D/M06-F | PARTIAL | `8936ad1` | 已提供控制箱控电、磁力锁、TTS、LED、订单任务、Sub-1G 门锁和智慧插座控制服务;`device.command` 任务可按订单开始、续费、取消、结束和换房下发 `task/addtask/canceltask` 与插座开关;终态订单不会延迟补发命令,命令返回 PUBLISHED 而非假成功。 | 顾客小程序一键入口待 M08;真实硬件属于 M06-G。 | M06-G 执行订单到硬件真实矩阵;M08 接入顾客/管理员界面。 |
| DEV-001 | 一键开门与设备动作 | M06-D/M06-F/M08 | PARTIAL | `874b9b8` | 已提供控制箱控电、磁力锁、TTS、LED、订单任务、Sub-1G 门锁和智慧插座控制服务;M08-A 新增顾客端订单权限开门入口,按本人有效订单和时间窗口校验后下发普通开门命令,命令返回 PUBLISHED 而非假成功;顾客端续费/同门店换房入口已接入订单调整 API。 | 真实硬件属于 M06-G;小程序真机开门、续费/换房后的设备动作仍待现场联调。 | 取得硬件后执行真实开门、续费和换房设备联动矩阵。 |
| IOT-002 | 控制箱接入 | M06-D | PARTIAL | `d15fd3f` | `ConctolPower/Crldoor/PlayTTS/stopTTS/CrlLED/task/addtask/canceltask` 已通过适配器、命令持久化和 API 接入。 | 缺控制箱实物逐路负载、门磁、TTS 和任务回包联调。 | M06-G 执行实物矩阵。 |
| IOT-003 | Sub-1G 门锁绑定 | M06-D | PARTIAL | `d15fd3f` | `AddDevice` 下发、超时窗口、回包 subID/subtype、701C/701G 型号映射和父子拓扑自动固化已实现。 | 缺现场 `*789#` 与实物配对。 | M06-G 现场配对、超时和换绑验收。 |
| IOT-004 | Sub-1G 门锁控制 | M06-D | PARTIAL | `d15fd3f` | `CtrlDevice` 开关门、密码/卡片和恢复出厂适配器已实现;危险清空/恢复出厂仅平台管理员加确认短语;record 内容哈希脱敏,低电量及 timeout/full/unconfirm 告警。 | 缺 701C/701G 实物及密码/卡片现场验收。 | M06-G 完成实物控制与故障场景。 |
| 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。 |
+8 -8
View File
@@ -1,17 +1,17 @@
# 模块状态
# 模块状态
```yaml
execution_cursor:
current_module: M08
current_stage: M08-A
stage_status: TODO
current_stage: M08-B
stage_status: PARTIAL
last_completed_stage: M07-D
next_stage: M08-A
last_engineering_commit: `ed0d455`
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-24T00:00: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 微信原生小程序完整业务 | TODO | - | - | - |
| 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 | - | - | - |
-9
View File
@@ -1,17 +1,8 @@
App({
onLaunch() {
// 记录本地启动时间,便于开发调试。
const logs = wx.getStorageSync('logs') || []
logs.unshift(Date.now())
wx.setStorageSync('logs', logs)
// 获取临时登录凭证;正式换取会话在 M08 接入。
wx.login({
success: res => {
// 后续将 res.code 发送到 /app-api/auth/wechat-login。
void res
}
})
},
globalData: {
userInfo: null
+6
View File
@@ -3,6 +3,12 @@
"pages/index/index",
"pages/store/detail",
"pages/room/detail",
"pages/orders/list",
"pages/orders/detail",
"pages/profile/index",
"pages/benefits/index",
"pages/recharge/index",
"pages/cleaner/tasks",
"pages/logs/logs"
],
"window": {
+80
View File
@@ -0,0 +1,80 @@
const { request, ensureLogin, cents } = require('../../utils/api.js')
Page({
data: {
loading: false,
errorMessage: '',
coupons: [],
packages: [],
},
onLoad() {
this.loadBenefits()
},
async loadBenefits() {
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
const response = await request('/profile/benefits')
this.setData({
coupons: (response.data.coupons || []).map(formatCoupon),
packages: (response.data.packages || []).map(formatPackage),
})
} catch (error) {
this.setData({ errorMessage: error.message || '权益明细加载失败' })
} finally {
this.setData({ loading: false })
}
},
})
function formatCoupon(item) {
const isTime = item.couponType === 'TIME'
return {
...item,
statusText: benefitStatus(item.status),
valueText: isTime ? `${item.timeMinutes || 0}分钟` : cents(item.discountAmountCents),
thresholdText: Number(item.minOrderAmountCents || 0) > 0
? `${cents(item.minOrderAmountCents)}可用`
: '无门槛',
validText: `${formatDate(item.validFrom)}${formatDate(item.validTo)}`,
scopeText: scopeText(item),
}
}
function formatPackage(item) {
return {
...item,
statusText: benefitStatus(item.status),
priceText: cents(item.priceCents),
remainingAmountText: cents(item.remainingAmountCents),
totalAmountText: cents(item.amountCentsTotal),
validText: `${formatDate(item.validFrom)}${formatDate(item.validTo)}`,
scopeText: scopeText(item),
}
}
function benefitStatus(status) {
return ({
AVAILABLE: '可用',
ACTIVE: '可用',
FROZEN: '锁定中',
USED: '已使用',
EXHAUSTED: '已用尽',
EXPIRED: '已过期',
})[status] || status
}
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 formatDate(value) {
const date = new Date(value)
const pad = (input) => String(input).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "权益明细"
}
+43
View File
@@ -0,0 +1,43 @@
<scroll-view class="scrollarea" scroll-y type="list">
<view class="page">
<view class="title">权益明细</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view class="section-title">优惠券</view>
<view wx:if="{{!loading && coupons.length === 0}}" class="empty">暂无优惠券</view>
<view wx:for="{{coupons}}" wx:key="couponGrantId" class="benefit-card">
<view class="card-head">
<view class="benefit-name">{{item.name}}</view>
<view class="status">{{item.statusText}}</view>
</view>
<view class="benefit-value">{{item.valueText}}</view>
<view class="muted">{{item.thresholdText}}</view>
<view class="muted">{{item.scopeText}}</view>
<view class="muted">{{item.validText}}</view>
<view class="muted">剩余次数:{{item.remainingUses}}</view>
</view>
<view class="section-title">套餐</view>
<view wx:if="{{!loading && packages.length === 0}}" class="empty">暂无套餐</view>
<view wx:for="{{packages}}" wx:key="packageHoldingId" class="benefit-card">
<view class="card-head">
<view class="benefit-name">{{item.name}}</view>
<view class="status">{{item.statusText}}</view>
</view>
<view class="row">
<text>剩余分钟</text>
<text>{{item.remainingMinutes}} / {{item.minutesTotal}}</text>
</view>
<view class="row">
<text>剩余金额</text>
<text>{{item.remainingAmountText}} / {{item.totalAmountText}}</text>
</view>
<view class="row">
<text>购买价</text>
<text>{{item.priceText}}</text>
</view>
<view class="muted">{{item.scopeText}}</view>
<view class="muted">{{item.validText}}</view>
</view>
</view>
</scroll-view>
+72
View File
@@ -0,0 +1,72 @@
.scrollarea {
height: 100vh;
background: #f5f6f8;
}
.page {
padding: 32rpx;
}
.title {
margin-bottom: 24rpx;
font-size: 40rpx;
font-weight: 600;
}
.section-title {
margin: 28rpx 0 16rpx;
font-size: 32rpx;
font-weight: 600;
}
.benefit-card {
margin-top: 20rpx;
padding: 28rpx;
border-radius: 16rpx;
background: #ffffff;
}
.card-head,
.row {
display: flex;
justify-content: space-between;
gap: 24rpx;
}
.benefit-name {
font-size: 32rpx;
font-weight: 600;
}
.status {
color: #1f6feb;
white-space: nowrap;
}
.benefit-value {
margin-top: 16rpx;
color: #b42318;
font-size: 44rpx;
font-weight: 700;
}
.row {
margin-top: 16rpx;
color: #475467;
}
.muted {
margin-top: 8rpx;
color: #667085;
}
.empty {
padding: 60rpx 0;
color: #888888;
text-align: center;
}
.error {
margin: 16rpx 0;
color: #c73535;
}

Some files were not shown because too many files have changed in this diff Show More