比较提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
bb53e6f542 | ||
|
|
a808064c73 | ||
|
|
5e517a4503 | ||
|
|
6cbe22f40a | ||
|
|
402e6cb45b | ||
|
|
8493c75ab4 | ||
|
|
59470f7b4c | ||
|
|
246e999244 | ||
|
|
92093b7368 | ||
|
|
ba9b41bd9e | ||
|
|
fe4bdd1e2d | ||
|
|
40e28d2d4e | ||
|
|
92b53da54d | ||
|
|
4f93f147be | ||
|
|
cfc9c7b8db | ||
|
|
45f5dc4a06 | ||
|
|
ad33e901ab | ||
|
|
c76d5f72c7 | ||
|
|
e8b8eea8f7 | ||
|
|
33a34f1e3d | ||
|
|
4f94800a05 | ||
|
|
9b32c2e2e0 | ||
|
|
8737f63a54 | ||
|
|
acb7c040bc | ||
|
|
8288bfcc7f | ||
|
|
199dad384e | ||
|
|
171be62be5 | ||
|
|
6b29df3f63 | ||
|
|
e9a625d8fb | ||
|
|
710ceba840 | ||
|
|
8f6c7678dd | ||
|
|
b279ae7eeb | ||
|
|
3740020603 | ||
|
|
1e7c98f270 | ||
|
|
2e94a6387c | ||
|
|
f7ebc9c6f9 | ||
|
|
4eb1e9db52 | ||
|
|
8d6c2f3375 | ||
|
|
e5b0d7d7e7 | ||
|
|
c8557f50b5 | ||
|
|
27de6f8837 | ||
|
|
bdb6ac7e92 | ||
|
|
5163692a3f | ||
|
|
b5b18f01d7 | ||
|
|
c352c72060 | ||
|
|
fc2ebf84ac | ||
|
|
94e3aa4525 | ||
|
|
3847d19b20 | ||
|
|
3e710dee3c | ||
|
|
bf6e7636ac | ||
|
|
e1bc127abc | ||
|
|
8f08de40b6 | ||
|
|
ab510d7e62 | ||
|
|
c3a5b2cd99 | ||
|
|
033d60adc9 | ||
|
|
00365d3d26 | ||
|
|
6a8f6d870b | ||
|
|
df88c7256f | ||
|
|
37e951eb49 | ||
|
|
14f6349b96 | ||
|
|
22a51bdd09 | ||
|
|
7f0b279d93 | ||
|
|
1eb4246e3a | ||
|
|
fd02530c35 | ||
|
|
e2b305a708 |
@@ -0,0 +1,46 @@
|
||||
name: changelog-filename-gate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- edited
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Keep the gate self-contained: the test environment cannot reliably clone GitHub Actions repositories.
|
||||
- name: Checkout full history
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/*:refs/remotes/origin/*" \
|
||||
"+refs/pull/*/head:refs/remotes/pull/*/head" \
|
||||
"+refs/pull/*/merge:refs/remotes/pull/*/merge"
|
||||
git checkout --detach "$GITHUB_SHA"
|
||||
|
||||
- name: Verify Node.js runtime
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
node -e "if (Number(process.versions.node.split('.')[0]) < 20) process.exit(1)"
|
||||
|
||||
- name: Run regression tests
|
||||
run: npm test
|
||||
|
||||
- name: Validate new changelog filenames
|
||||
run: npm run check:filenames -- --event "$GITHUB_EVENT_PATH"
|
||||
|
||||
- name: Validate changelog frontmatter
|
||||
run: npm run check:frontmatter -- --event "$GITHUB_EVENT_PATH"
|
||||
@@ -0,0 +1,75 @@
|
||||
# 后端 API Changelog 推送说明
|
||||
|
||||
接口发生新增、修改或删除时,在 `hl-api-changelog` 仓库提交一份 changelog。
|
||||
|
||||
## 1. 放在哪里
|
||||
|
||||
- 管理后台:`changelogs-v2/YYYY-MM/`
|
||||
- 小程序:`changelogs-v2-mp/YYYY-MM/`
|
||||
|
||||
文件名:
|
||||
|
||||
```text
|
||||
DD_issue_业务标题-{新增接口|修改接口|删除接口}-{管理后台|小程序端}.md
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理后台.md
|
||||
```
|
||||
|
||||
日期使用提交时的上海日期;不要把“前端待处理”“已完成”等状态写进文件名。
|
||||
|
||||
## 2. 写什么
|
||||
|
||||
可以复制仓库根目录的 `CHANGELOG_TEMPLATE.md`,至少写清:
|
||||
|
||||
- 关联的 Issue 和后端 PR;
|
||||
- 接口路径和 HTTP 方法;
|
||||
- 新增、修改或删除的请求/响应字段;
|
||||
- 字段必填性、枚举、状态、空值、金额和兼容规则;
|
||||
- 前端需要做什么;
|
||||
- 后端测试、部署和网关验证结果。
|
||||
|
||||
元数据中:
|
||||
|
||||
```yaml
|
||||
backend_status: "deployed"
|
||||
gateway_status: "verified"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
```
|
||||
|
||||
- 需要前端修改:`frontend_status: "pending"`
|
||||
- 不需要前端修改:`frontend_status: "not_required"`
|
||||
- 后端不要代替前端填写 `implemented`、`released` 或 `verified`
|
||||
|
||||
## 3. 校验
|
||||
|
||||
在 `hl-api-changelog` 仓库执行:
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
npm run check:filenames -- --base origin/main --head HEAD
|
||||
npm run check:frontmatter -- --base origin/main --head HEAD
|
||||
```
|
||||
|
||||
确保正文没有 `TODO`、`待补充` 或模板占位符。
|
||||
|
||||
## 4. 提交和推送
|
||||
|
||||
只暂存本次 changelog 文件:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git add -- changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理后台.md
|
||||
git diff --cached --check
|
||||
git commit -m "docs: hand off API contract (#5205)"
|
||||
git push -u origin <任务分支>
|
||||
```
|
||||
|
||||
然后向 `main` 创建 PR。不要提交其他任务的 changelog、`.tmp-*` 文件或任何凭据。
|
||||
@@ -1,3 +1,21 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "{issue-no}"
|
||||
title: "{一句话概括变化}"
|
||||
consumer: "{admin|mp|internal|multiple}"
|
||||
change_type: "{新增接口|修改接口|删除接口}"
|
||||
backend_status: "pending"
|
||||
gateway_status: "pending"
|
||||
frontend_status: "{pending|not_required}"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: ""
|
||||
updated_at: "YYYY-MM-DD"
|
||||
base: "{dev|dev-v3}"
|
||||
---
|
||||
|
||||
# {模块名}: {一句话概括变化}
|
||||
|
||||
> **存放目录**:
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# Changelog 贡献规则
|
||||
|
||||
## 二期文件名
|
||||
|
||||
`/v3/admin/*` 接口写入 `changelogs-v2/`:
|
||||
|
||||
```text
|
||||
changelogs-v2/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除接口}-管理后台.md
|
||||
```
|
||||
|
||||
`/v3/mp/*` 接口写入 `changelogs-v2-mp/`:
|
||||
|
||||
```text
|
||||
changelogs-v2-mp/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除接口}-小程序端.md
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `YYYY-MM` 和 `DD` 必须是校验运行时 `Asia/Shanghai` 的真实年月日,且均须补齐两位。跨越上海零点后仍未合并的 PR,需要把新文件重命名为当天日期。
|
||||
- `issue` 必须是不带 `#` 的十进制正整数,不允许 `0`、负数或前缀符号。
|
||||
- 业务标题不能为空。
|
||||
- 变更类型只能是 `新增接口`、`修改接口` 或 `删除接口`。
|
||||
- 端类型由目录唯一决定:`changelogs-v2/` 固定为 `管理后台`,`changelogs-v2-mp/` 固定为 `小程序端`。
|
||||
- 同一改动同时影响 `/v3/admin/*` 和 `/v3/mp/*` 时,应按目录拆成两份。
|
||||
|
||||
一期 `changelogs/` 沿用现行格式,不套用上述强制模板。
|
||||
|
||||
## 校验范围
|
||||
|
||||
检测器读取 `git diff --name-status -z --find-renames` 的结果,只校验本次 diff 新出现的目标路径:
|
||||
|
||||
- `A`(新增)、`C`(复制)和 `R`(重命名)的目标路径必须通过规则。
|
||||
- `M`(修改历史文件)和 `D`(删除)豁免,不会因存量错误命名阻断。
|
||||
- 重命名到受控目录时,新目标路径必须使用校验当天的上海日期。
|
||||
|
||||
本地校验:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run check:filenames -- --base origin/main --head HEAD
|
||||
```
|
||||
|
||||
生产 CLI 故意不提供 `--date` 或日期环境变量;测试只通过导出的纯函数注入 `Date`。规则失败返回退出码 `1`,Git/事件/参数等基础设施错误返回 `2`。
|
||||
|
||||
## 前端消费状态
|
||||
|
||||
新增二期 changelog 必须使用 `hl-changelog/v2` YAML Front Matter。状态只写在元数据中,不写入文件名:
|
||||
|
||||
```yaml
|
||||
backend_status: "deployed"
|
||||
gateway_status: "verified"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
updated_at: "2026-07-24"
|
||||
```
|
||||
|
||||
前端状态正常流转为:
|
||||
|
||||
```text
|
||||
pending → claimed → implemented → released → verified
|
||||
```
|
||||
|
||||
不需要前端修改时使用 `not_required`。字段一致性、必填证据和新增文档 frontmatter 由 `check:frontmatter` 校验。
|
||||
|
||||
完整职责和命令见:
|
||||
|
||||
- `FRONTEND_CONSUMPTION_STATUS_GUIDE.md`
|
||||
- `BACKEND_CHANGELOG_DELIVERY_GUIDE.md`
|
||||
|
||||
本地校验:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run check:filenames -- --base origin/main --head HEAD
|
||||
npm run check:frontmatter -- --base origin/main --head HEAD
|
||||
```
|
||||
|
||||
## CI 与服务端阻断边界
|
||||
|
||||
Gitea Actions 会在指向 `main` 的 PR 和 `main` 的 push 上运行回归测试与文件名检测。该 workflow 是检测器:
|
||||
|
||||
- 在 `main` 未开启分支保护和 required status 时,失败状态不能硬性阻止合并。
|
||||
- `push` 事件发生在写入之后,只能检测/报警,不能撤销直推。
|
||||
- 只有管理员另行保护 `main`、关闭直推,并在 workflow 首次成功运行后,从 Gitea 最近上报的 status context 列表中选择实际值作为 required status,才能宣称服务端 hard gate 已激活。激活记录必须保存首次运行链接和 status API/分支保护回读证据;不得预设 job id/name `validate` 就是 Gitea 实际上报的 context。
|
||||
|
||||
因此,本仓库文件交付的准确表述是:**detector 已安装;在 `main` 未保护时,服务端 hard gate 尚未激活。**
|
||||
@@ -0,0 +1,109 @@
|
||||
# API Changelog 前端消费状态协作说明
|
||||
|
||||
## 可直接转发给前端的通知
|
||||
|
||||
API changelog 从 `hl-changelog/v2` 开始记录前端消费进度。后端交接时会填写:
|
||||
|
||||
```yaml
|
||||
backend_status: "deployed"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
```
|
||||
|
||||
请前端在领取、实现、发布和页面验证时更新对应状态,并填写可追溯的前端 PR、提交或发布版本。
|
||||
|
||||
这不会把前端工作纳入后端工单验收,也不要求在 `hl-ui` 创建配合工单。它只用于区分:
|
||||
|
||||
- 后端接口是否已经部署并验证;
|
||||
- 前端是否已经领取;
|
||||
- 前端代码是否已经实现;
|
||||
- 页面是否已经发布并验证。
|
||||
|
||||
只有 `frontend_status: "verified"` 才表示用户页面形成完整闭环。
|
||||
|
||||
## 状态流转
|
||||
|
||||
```text
|
||||
pending → claimed → implemented → released → verified
|
||||
```
|
||||
|
||||
不需要前端修改时:
|
||||
|
||||
```text
|
||||
not_required
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 必填证据 |
|
||||
|---|---|---|
|
||||
| `not_required` | 不需要前端修改 | 不填写前端负责人、引用和版本 |
|
||||
| `pending` | 等待前端领取 | 无 |
|
||||
| `claimed` | 前端已领取 | `frontend_owner` |
|
||||
| `implemented` | 前端代码已实现 | `frontend_owner`、`frontend_ref` |
|
||||
| `released` | 已发布 | 再填写 `target_release` |
|
||||
| `verified` | 页面已验证 | 再填写 `verified_at` |
|
||||
|
||||
跨级迁移会被自动校验拒绝。状态回退或改为/取消 `not_required` 时必须填写原因。
|
||||
|
||||
## 更新命令
|
||||
|
||||
领取:
|
||||
|
||||
```powershell
|
||||
hl changelog transition 5205 D:/path/changelog.md claimed `
|
||||
--owner frontend-team --write
|
||||
```
|
||||
|
||||
实现:
|
||||
|
||||
```powershell
|
||||
hl changelog transition 5205 D:/path/changelog.md implemented `
|
||||
--owner frontend-team `
|
||||
--frontend-ref "mmg/hl-ui@abc1234" `
|
||||
--write
|
||||
```
|
||||
|
||||
发布:
|
||||
|
||||
```powershell
|
||||
hl changelog transition 5205 D:/path/changelog.md released `
|
||||
--target-release "test-2026.07.24" `
|
||||
--write
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```powershell
|
||||
hl changelog transition 5205 D:/path/changelog.md verified `
|
||||
--verified-at "2026-07-24" `
|
||||
--write
|
||||
```
|
||||
|
||||
命令默认只预览;只有 `--write` 才修改文件。写入命令会自动获取 `changelog` 单写租约。
|
||||
|
||||
## 职责边界
|
||||
|
||||
后端负责:
|
||||
|
||||
- 完成后端测试、部署和网关验证;
|
||||
- 初始化 v2 元数据;
|
||||
- 需要前端时设置 `pending`,不需要时设置 `not_required`;
|
||||
- 不替前端填写 `implemented`、`released` 或 `verified`。
|
||||
|
||||
前端负责:
|
||||
|
||||
- 领取时填写负责人;
|
||||
- 实现后填写前端引用;
|
||||
- 发布后填写目标版本或环境;
|
||||
- 页面验证后填写验证日期。
|
||||
|
||||
QA 或产品可以协助更新 `verified_at`,但必须基于实际页面验证,不能只根据接口成功或代码已合并标记完成。
|
||||
|
||||
## 存量文档
|
||||
|
||||
- 新 changelog 全部使用 `hl-changelog/v2`。
|
||||
- `hl-changelog/v1` 继续可读和索引,不强制一次性迁移。
|
||||
- 文件名带“前端待处理”不代表真实状态;需要继续流转时补充 v2 元数据。
|
||||
- 不通过重命名表达消费状态,避免破坏文件名校验和历史链接。
|
||||
@@ -44,3 +44,4 @@ body:`{"orderId": 2071784830965620737}`(必填;可选 `peer`)
|
||||
- 车务发消息→定制师收到(unread+1);车务读→团队共享水位推进(任一车务读全体清零)。
|
||||
- 单测 user-service 3099 绿(房务基线零回归)。
|
||||
- **看板列表红点(PR #4698)实测**:`/admin/fleet/board/orders` 74 单每行含 `orderId`(真数字雪花)+ `unreadMessageCount`;定制师向某单广播一条→重查该行 `unreadMessageCount=1`(团队未读联动看板);user-service 软降级路径不阻断列表。fleet-service 全量 1307 测试绿、ArchTest 11/11。
|
||||
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
# 【修改接口·管理后台】核单 Step1 住宿成本字段 (#5043)
|
||||
|
||||
> **PR**: #5047 | **服务**: hl-order-service-v3 | **更新时间**: 2026-07-18 16:50
|
||||
> **PR**: #5047 / #5162 | **服务**: hl-order-service-v3 | **更新时间**: 2026-07-23 09:53
|
||||
|
||||
## 1. 接口背景
|
||||
|
||||
核单 Step1 住宿成本明细需要对齐原型里的酒店资源、房型资源、核算单价、来源和确认状态展示。现有接口保留原路径,在 `GET/PUT /v3/admin/order/{orderId}/settlement/step1` 上做兼容增强。
|
||||
|
||||
### 1.1 2026-07-23 前端对接补充(数据来源提交约定)
|
||||
|
||||
`sourceType` 只用于区分住宿明细的数据来源,不决定行是否可编辑。行的可编辑性继续由 `settlementConfirmStatus` 控制,本次不新增 `deletable` 或 `sourceFieldsEditable` 字段。
|
||||
|
||||
| 数据来源 | GET 返回 / PUT 回传的 `sourceType` | PUT 回传的 `sourceId` | PUT 回传的 `hotelAssignmentId` |
|
||||
|----------|------------------------------------------|----------------------------|---------------------------------------|
|
||||
| 后台配房自动行 | `HOUSE_ASSIGNMENT` | 原样回传 GET 返回的配房记录 ID | 原样回传 GET 返回的配房记录 ID |
|
||||
| 前端手动新增行 | `MANUAL` | `null` | `null` |
|
||||
|
||||
`sourceType` 当前仍为非必填字段:未传时,后端可根据 `hotelAssignmentId` 推断数据来源。为了稳定保留来源信息,前端保存时应按上表显式回传。
|
||||
|
||||
**后台配房自动行 PUT 关键字段**:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceType": "HOUSE_ASSIGNMENT",
|
||||
"sourceId": "2077233886282088401",
|
||||
"hotelAssignmentId": "2077233886282088401",
|
||||
"settlementConfirmStatus": "UNCONFIRMED"
|
||||
}
|
||||
```
|
||||
|
||||
**前端手动新增行 PUT 关键字段**:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceType": "MANUAL",
|
||||
"sourceId": null,
|
||||
"hotelAssignmentId": null,
|
||||
"settlementConfirmStatus": "UNCONFIRMED"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 变更清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
@@ -43,7 +76,7 @@
|
||||
|------|------|------|------|----------|
|
||||
| `items` | array | 是 | 住宿成本明细行数组,全量替换保存 | 不允许为 `null` |
|
||||
| `items[].id` | string | 否 | 已存在行 ID;全量替换保存时可不传 | 长整型字符串 |
|
||||
| `items[].hotelAssignmentId` | string/null | 否 | 配房 assignment ID;手工行可为 `null` | 长整型字符串或 `null` |
|
||||
| `items[].hotelAssignmentId` | string/null | 否 | 配房 assignment ID;后台配房自动行应原样回传,手动行传 `null` | 长整型字符串或 `null` |
|
||||
| `items[].hotelId` | string/null | 否 | 酒店资源 ID;本次新增 | 长整型字符串或 `null` |
|
||||
| `items[].roomTypeId` | string/null | 否 | 房型资源 ID;本次新增 | 长整型字符串或 `null` |
|
||||
| `items[].stayDate` | string | 是 | 入住日期 | `yyyy-MM-dd`,不能早于订单出发日 |
|
||||
@@ -57,9 +90,9 @@
|
||||
| `items[].paymentMethod` | string | 否 | 付款方式;与 `settleType` 二选一 | `SIGNED` / `COMPANY_PAID` / `CASH_PAID` |
|
||||
| `items[].paymentMethodName` | string | 否 | 付款方式中文名,仅展示字段;保存时可不传;本次新增 | 最大 32 字符 |
|
||||
| `items[].settleType` | string | 否 | 配房结算类型;与 `paymentMethod` 二选一 | `cash` / `sign` / `company` |
|
||||
| `items[].sourceType` | string | 否 | 来源类型;本次新增;不传时按是否有 `hotelAssignmentId` 派生 | `HOUSE_ASSIGNMENT` / `MANUAL` / `TEMPLATE` |
|
||||
| `items[].sourceType` | string | 否 | 仅标识数据来源;后台配房行回传 `HOUSE_ASSIGNMENT`,手动行传 `MANUAL`;不传时后端按 `hotelAssignmentId` 推断 | `HOUSE_ASSIGNMENT` / `MANUAL` / `TEMPLATE` |
|
||||
| `items[].sourceTypeName` | string | 否 | 来源类型中文名,仅展示字段;保存时可不传;本次新增 | 最大 32 字符 |
|
||||
| `items[].sourceId` | string/null | 否 | 来源业务 ID;本次新增;配房来源默认等于 `hotelAssignmentId` | 长整型字符串或 `null` |
|
||||
| `items[].sourceId` | string/null | 否 | 来源业务 ID;后台配房自动行应原样回传配房记录 ID,手动行传 `null` | 长整型字符串或 `null` |
|
||||
| `items[].settlementConfirmStatus` | string | 否 | 核单确认状态;本次新增;不传默认 `CONFIRMED` | `UNCONFIRMED` / `CONFIRMED` |
|
||||
| `items[].settlementConfirmStatusName` | string | 否 | 核单确认状态中文名,仅展示字段;保存时可不传;本次新增 | 最大 32 字符 |
|
||||
| `items[].remark` | string/null | 否 | 备注 | 最大 500 字符 |
|
||||
@@ -294,8 +327,10 @@ Content-Type: application/json
|
||||
## 9. 业务边界
|
||||
|
||||
- `PUT` 是全量替换保存;前端保存时应提交页面当前完整明细列表。
|
||||
- `hotelAssignmentId` 有值时通常表示配房来源;`hotelAssignmentId` 为空时通常表示手工补充行。
|
||||
- GET 返回的后台配房自动行,PUT 保存时应原样回传 `sourceType=HOUSE_ASSIGNMENT`、`sourceId` 和 `hotelAssignmentId`,两个 ID 都是对应配房记录 ID。
|
||||
- 前端手动新增行,PUT 保存时传 `sourceType=MANUAL`、`sourceId=null`、`hotelAssignmentId=null`。
|
||||
- `sourceType` 不传时,`hotelAssignmentId` 有值默认 `HOUSE_ASSIGNMENT`,否则默认 `MANUAL`。
|
||||
- `sourceType` 只区分数据来源,不决定可编辑性;可编辑性继续由 `settlementConfirmStatus` 控制。
|
||||
- `sourceId` 不传且 `sourceType=HOUSE_ASSIGNMENT` 时,默认使用 `hotelAssignmentId`;其他来源可为 `null`。
|
||||
- `unitPrice` 不传且 `roomCount > 0`、`actualCost` 有值时,返回时会按 `actualCost / roomCount` 保留 2 位小数。
|
||||
- `settlementConfirmStatus` 不传时默认 `CONFIRMED`。
|
||||
@@ -353,6 +388,9 @@ Content-Type: application/json
|
||||
- **Issue**: [#5043](https://git.1814.love:8443/wx/HL/issues/5043)
|
||||
- **PR**: [#5047](https://git.1814.love:8443/wx/HL/pulls/5047)
|
||||
- **Merge commit**: [0a9e83b39](https://git.1814.love:8443/wx/HL/commit/0a9e83b390d135c05b43bc18afe1b190329bf85c)
|
||||
- **对接补充 Issue**: [#5157](https://git.1814.love:8443/wx/HL/issues/5157)
|
||||
- **对接补充 PR**: [#5162](https://git.1814.love:8443/wx/HL/pulls/5162)
|
||||
- **对接补充 Merge commit**: [bc5669fd5](https://git.1814.love:8443/wx/HL/commit/bc5669fd5)
|
||||
|
||||
### 13.2 联系人
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# 车务订单聊天前端入口与未读提醒
|
||||
|
||||
## 前端交接(截图反馈)
|
||||
|
||||
- 派单看板订单卡接入 `unreadMessageCount`:大于 0 显示消息红点,超过 9 显示 `9+`。
|
||||
- 派单看板订单卡增加「联系定制师」按钮,使用该行 `orderId` 调用 `POST /admin/message/chat/open-fleet`,不要使用展示号 `id`。
|
||||
- 订单详情弹窗增加「联系定制师」按钮,复用房务聊天抽屉和同一 `open-fleet` 接口;打开会话后自动标记已读。
|
||||
- 右上角消息提醒沿用 `/admin/message/unread-count` 初始拉取 + SSE 刷新,和房务保持一致。
|
||||
|
||||
详细接口契约见同目录 `02_4689_车务订单聊天_定制师车务团队_新接口_管理后台.md`。
|
||||
@@ -0,0 +1,145 @@
|
||||
# 订单详情「联系车务」独立未读红点
|
||||
|
||||
> 日期:2026-07-23
|
||||
> 工单:HL #5180
|
||||
> 影响范围:管理后台订单详情 → 行程安排 → 用车安排;聊天 SSE 实时状态
|
||||
> 状态:前端待处理
|
||||
|
||||
## 1. 问题与口径
|
||||
|
||||
车务通过 `FLEET:{orderId}` 会话给定制师发送消息后,顶部全局铃铛能显示未读,但当前订单「联系车务」按钮没有订单维度红点。房务按钮已有同款能力,本次车务必须复用相同的角标样式、实时刷新和已读清零交互。
|
||||
|
||||
房务与车务未读是两个独立业务会话,禁止继续共用一个字段:
|
||||
|
||||
- `unreadMessageCount`:当前登录定制师在本订单 `HOUSE:{orderId}` 会话的未读数,只供「联系房务」使用。
|
||||
- `fleetUnreadMessageCount`:当前登录定制师在本订单 `FLEET:{orderId}` 会话的未读数,只供「联系车务」使用。
|
||||
- 顶部铃铛全局未读只能说明“存在未读”,不能作为当前订单按钮是否亮红点的判断依据。
|
||||
|
||||
## 2. 接口变更
|
||||
|
||||
```http
|
||||
GET /v3/admin/order/{id}/itinerary
|
||||
```
|
||||
|
||||
响应新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"unreadMessageCount": 0,
|
||||
"fleetUnreadMessageCount": 2,
|
||||
"canContactFleet": true,
|
||||
"contactFleetDisabledReason": null
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `unreadMessageCount` | Integer | HOUSE 订单会话未读;既有字段,语义不变 |
|
||||
| `fleetUnreadMessageCount` | Integer | FLEET 订单会话未读;新增字段;无会话、未登录或软依赖降级时为 `0` |
|
||||
|
||||
两个字段必须分别消费,不能用 `fleetUnreadMessageCount || unreadMessageCount` 一类兜底混用,否则房务消息会错误点亮车务按钮。
|
||||
|
||||
后端内部取数同时增加可选 `unreadScope`:
|
||||
|
||||
- FLEET 不传或传 `TEAM`:保持车务看板既有团队共享未读口径。
|
||||
- FLEET 传 `PERSONAL`:按指定 `adminId` 返回车务发给该定制师的个人未读;订单详情的 `fleetUnreadMessageCount` 使用此口径。
|
||||
- HOUSE:仍按成员行个人未读统计,行为不变。
|
||||
|
||||
该字段属于 order-v3 → user-service 内部契约,管理后台无需直接传递。
|
||||
|
||||
## 3. 前端实现要求
|
||||
|
||||
### 3.1 按钮角标
|
||||
|
||||
`VehicleArrangeCard.vue` 的「联系车务」按钮按 `RoomArrangeCard.vue` 原样复用 `NBadge`:
|
||||
|
||||
- `fleetUnreadMessageCount > 0` 时显示红色数字角标。
|
||||
- `max=99`,`0` 自动隐藏。
|
||||
- 按钮禁用时仍可保留未读提示,不能因为 `canContactFleet=false` 静默吞掉既有会话未读;是否允许重新开会话继续遵守后端门控。
|
||||
- 样式、偏移、尺寸与「联系房务」保持一致,不新增另一套红点 CSS。
|
||||
|
||||
`v3Adapter.js` 需把行程接口的 `fleetUnreadMessageCount` 映射为独立本地字段(建议 `itineraryFleetUnreadCount`),不要覆盖现有 `itineraryUnreadCount`。
|
||||
|
||||
### 3.2 SSE 实时刷新
|
||||
|
||||
订单详情现有 `lastChatSignal` 监听只匹配 `HOUSE:{orderId}`。需要同时支持:
|
||||
|
||||
```text
|
||||
HOUSE:{orderId} -> 刷新联系房务角标
|
||||
FLEET:{orderId} -> 刷新联系车务角标
|
||||
```
|
||||
|
||||
收到当前订单的 `FLEET:{orderId}` `im-chat` / `im-chat-read` 信令后,轻量重拉行程接口并只合并 `fleetUnreadMessageCount`;不能整页闪骨架屏,也不能把其他订单的全局未读数套到当前订单。
|
||||
|
||||
### 3.3 已读清零与竞态
|
||||
|
||||
- 打开「联系车务」并收到聊天抽屉 `read` 事件后,立即把当前订单车务角标本地清零。
|
||||
- 房务已读只清 HOUSE 字段,车务已读只清 FLEET 字段,互不影响。
|
||||
- 复用房务现有 `chatReadEpoch`(或等价版本号)防竞态:已读期间较早发出的刷新请求返回时,不得把旧未读数重新覆盖成红点。
|
||||
- 切换订单时按新 `orderId` 重算,不能沿用上一个订单角标。
|
||||
|
||||
## 4. 验收场景
|
||||
|
||||
- [ ] 当前订单无未读时,「联系车务」不显示角标。
|
||||
- [ ] 车务给本单定制师发送 1 条消息后,不刷新页面,顶部铃铛和本单「联系车务」都立即显示红点/数字 `1`。
|
||||
- [ ] 当前订单无车务未读、其他订单有车务未读时,顶部铃铛可亮,但当前订单「联系车务」不亮。
|
||||
- [ ] 当前订单只有房务未读时,只点亮「联系房务」,不得点亮「联系车务」。
|
||||
- [ ] 打开本单车务会话并读完后,「联系车务」角标立即消失,顶部铃铛与站内信列表同步收敛。
|
||||
- [ ] 已读操作与 SSE 刷新并发时,旧请求不会让已清零红点复现。
|
||||
- [ ] 角标样式、最大数字和按钮布局与房务模块一致。
|
||||
|
||||
## 5. 兼容性
|
||||
|
||||
新增响应字段为加性变更。未接入新字段的旧前端行为不变;前端接入后仍使用既有 `POST /admin/message/chat/open-fleet` 打开会话,`orderId` 必须使用数字雪花字符串,不能使用 `HL...` 展示号。
|
||||
|
||||
## 6. 2026-07-23 车务看板回归补充
|
||||
|
||||
本节针对车务管理员在「派单看板」点击「联系定制师」的反向会话入口。它使用车务团队
|
||||
`TEAM` 未读口径,不得复用定制师订单详情的 `PERSONAL` 字段。
|
||||
|
||||
### 6.1 首次点击必须立即打开真实会话
|
||||
|
||||
当前 `FleetBoard` 在首次点击时同一轮设置 `chatOrder` 和 `chatOpen=true`,随后通过
|
||||
`v-if="chatOrder"` 首次挂载 `ChatDrawer`。`ChatDrawer` 对 `props.show` 的 watcher 没有
|
||||
立即执行,因此组件以 `show=true` 首次挂载时不会调用 `open-fleet`,只显示默认「对端」
|
||||
和空线程;关闭后第二次发生 `false -> true` 才会正常调用接口。
|
||||
|
||||
前端需要修复该生命周期缺口:
|
||||
|
||||
- `ChatDrawer` 首次挂载且 `show=true` 时必须执行一次 `openFlow()`;建议在现有合并 watcher
|
||||
保留 `flush: 'post'` 并增加 `immediate: true`,或采用等价的挂载处理。
|
||||
- 首次点击只能调用一次 `POST /admin/message/chat/open-fleet`,不能因 `show/bizId` 同轮变化
|
||||
重复打开或让后一个请求取消前一个请求。
|
||||
- 首次接口响应后立即展示真实 `peerName/peerRoleLabel/thread`;加载完成前保持 loading,
|
||||
不能先落成可交互的「对端」空会话。
|
||||
- `show=false` 首次挂载不得调用打开接口;之后每次 `false -> true` 仍只调用一次。
|
||||
|
||||
### 6.2 车务看板未读角标必须实时刷新
|
||||
|
||||
车务看板列表、密集视图和详情抽屉虽然已经消费 `unreadMessageCount`,但当前只订阅
|
||||
`useFleetDispatchRefresh` 的派车业务信令,没有订阅聊天总线 `lastChatSignal`。因此定制师
|
||||
发来 `FLEET:{orderId}` 新消息后只能整页刷新才出现角标。
|
||||
|
||||
前端需要按房务模块的方式补齐:
|
||||
|
||||
- 监听全局 `lastChatSignal`,仅处理当前页订单的 `FLEET:{orderId}` `im-chat/im-chat-read`
|
||||
信令;其他模块和其他订单不得误刷新角标。
|
||||
- 信令只表示“数据变化”,不能把顶部铃铛的合并未读数直接写进订单。应轻量重拉当前筛选/
|
||||
分页的车务看板订单接口,并只合并对应行的 `unreadMessageCount`。
|
||||
- 轻量刷新不得切换全页 loading、闪白、重置筛选、分页或滚动位置;短时间连续信令需要合并。
|
||||
- 列表 `orders`、当前 `activeOrder`、当前 `chatOrder` 的同一订单计数必须一起收敛。
|
||||
- 打开会话收到 `read` 后立即本地清零,并使用读态纪元/刷新序号防止较早发出的异步刷新把
|
||||
旧未读数重新覆盖回来。
|
||||
- 聊天抽屉正在打开当前会话时由 `ChatDrawer` 实时拉消息并标已读;看板 watcher 不得与其
|
||||
抢写非零计数。
|
||||
|
||||
### 6.3 回归验收
|
||||
|
||||
- [ ] 清缓存后首次点击任一订单「联系定制师」,只发起一次 `open-fleet`,直接显示真实定制师及历史消息,不出现「对端」空会话。
|
||||
- [ ] 关闭再打开同一订单,行为与首次一致,不依赖“点第二次才正常”。
|
||||
- [ ] 定制师给该订单发送 1 条新消息后,车务不刷新页面即可在对应订单按钮看到房务同款红色数字角标。
|
||||
- [ ] 新消息只更新对应订单;其他订单、HOUSE 会话和顶部全局未读不得误点亮该按钮。
|
||||
- [ ] 实时更新角标时页面不闪 loading,筛选、分页、滚动位置保持不变。
|
||||
- [ ] 打开会话后角标立即清零;异步刷新晚返回也不会让红点复现。
|
||||
- [ ] 列表视图、密集视图、订单详情抽屉三处计数一致。
|
||||
- [ ] 增加组件测试:`ChatDrawer(show=true)` 首挂打开一次、`show=false` 首挂不打开、聊天信令轻量更新/已读竞态不复亮。
|
||||
@@ -0,0 +1,425 @@
|
||||
# 【🔧 修改接口·管理后台】Step2 票种规格默认值(#5185)
|
||||
|
||||
> **PR**: #5191 | **更新时间**: 2026-07-23
|
||||
|
||||
## 1. 接口背景
|
||||
|
||||
Step2 门票/游玩项目明细原先可能返回或保存空的 `specName`,前端无法稳定展示票种/规格。现在查询和保存统一补齐“成人票”默认值,同时保留已有的非空规格。
|
||||
|
||||
## 2. 变更清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | Step2 查询门票核单明细 | GET | `/v3/admin/order/{orderId}/settlement/step2` | 修改接口 | 无明确规格的明细统一返回 `specName=成人票` |
|
||||
| 2 | Step2 保存门票核单明细 | PUT | `/v3/admin/order/{orderId}/settlement/step2` | 修改接口 | `specName` 为 `null`、空串或纯空白时按“成人票”保存 |
|
||||
|
||||
## 3. 接口详情
|
||||
|
||||
### 3.1 Step2 查询门票核单明细
|
||||
|
||||
- **使用场景**:进入或刷新核单 Step2 时查询门票/游玩项目明细。
|
||||
- **认证**:需要管理后台 JWT。
|
||||
- **幂等性**:是,只读查询。
|
||||
- **限流**:无接口专属限流约定。
|
||||
- **默认语义**:自动生成且无明确规格、或已有明细规格为空时,`specName` 返回“成人票”。
|
||||
- **保留语义**:已有非空规格原样返回,例如“骑马体验”。
|
||||
|
||||
### 3.2 Step2 保存门票核单明细
|
||||
|
||||
- **使用场景**:全量保存 Step2 门票/游玩项目明细。
|
||||
- **认证**:需要管理后台 JWT。
|
||||
- **幂等性**:业务数据为全量替换语义;重复提交相同明细得到相同业务内容,行 ID 可能重新生成。
|
||||
- **限流**:无接口专属限流约定。
|
||||
- **默认语义**:`items[].specName` 为 `null`、`""` 或纯空白时,保存并回读为“成人票”。
|
||||
- **保留语义**:非空规格原样保存,例如“骑马体验”不会被替换。
|
||||
|
||||
## 4. 接口入参
|
||||
|
||||
### 4.1 路径参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `orderId` | Long / String | 是 | 订单 ID,必须大于 `0`;19 位 ID 建议按字符串拼入路径 |
|
||||
|
||||
GET 无 Query 参数、无请求体。
|
||||
|
||||
### 4.2 PUT 请求体
|
||||
|
||||
推荐使用对象形式;接口同时兼容直接提交明细数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 | 校验规则 |
|
||||
|------|------|------|------|----------|
|
||||
| `items` | Array | 是 | 门票/游玩项目明细,全量替换 | 可为空数组;空数组表示清空已保存草稿 |
|
||||
|
||||
### 4.3 `items[]` 字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 | 校验规则 |
|
||||
|------|------|------|------|----------|
|
||||
| `id` | Long / String | 否 | 已存在行 ID;新增或自动生成行可为空 | 19 位 ID 建议使用字符串 |
|
||||
| `sourceType` | String | 是 | 来源类型 | `SCENIC_ASSIGNMENT`、`ACTIVITY_ASSIGNMENT`、`CUSTOM_ASSIGNMENT` |
|
||||
| `sourceTypeName` | String | 否 | 来源类型中文名 | 最长 32 字符 |
|
||||
| `scenicAssignmentId` | Long / String | 否 | 来源记录 ID;手动补充行为空 | 19 位 ID 建议使用字符串 |
|
||||
| `dayNumber` | Integer | 否 | 行程第几天,从 `1` 开始;保存时按 `dayDate` 计算 | 无需前端计算 |
|
||||
| `dayDate` | String | 是 | 项目日期 | `yyyy-MM-dd`,不得早于订单出发日期 |
|
||||
| `scenicName` | String | 是 | 景区或游玩项目名称 | 非空,最长 200 字符 |
|
||||
| `specName` | String | 否 | 票种/规格名称 | 最长 128 字符;`null`、空串、纯空白统一为“成人票” |
|
||||
| `ticketCount` | Integer | 是 | 实际购票数量 | 套餐含项目可填 `0` |
|
||||
| `ticketUnitPrice` | Decimal | 否 | 参考单价,单位元 | 自费项目可填;包价项目可为 `null` |
|
||||
| `sellPrice` | Decimal | 否 | 客户成交单价,单位元 | 大于等于 `0` |
|
||||
| `totalAmount` | Decimal | 否 | 客户成交小计,单位元 | 大于等于 `0`;为空时按 `sellPrice × ticketCount` 计算 |
|
||||
| `plannedCost` | Decimal | 是 | 计划成本,单位元 | 大于等于 `0` |
|
||||
| `actualCost` | Decimal | 是 | 实际成本,单位元 | 大于等于 `0` |
|
||||
| `paymentMethod` | String | 否 | 付款方式 | `SIGNED`、`COMPANY_PAID`、`CASH_PAID`;为空时为 `COMPANY_PAID` |
|
||||
| `paymentMethodName` | String | 否 | 付款方式中文名 | 最长 32 字符 |
|
||||
| `voucherUrls` | Array\<String> | 否 | 凭证图片 URL 列表 | 可为空数组 |
|
||||
| `remark` | String | 否 | 备注 | 最长 500 字符 |
|
||||
|
||||
## 5. 出参(响应)
|
||||
|
||||
### 5.1 统一响应字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `code` | Integer | 业务状态码,成功为 `200` |
|
||||
| `message` | String | 响应消息,成功为“成功” |
|
||||
| `data` | Object / Array | GET 为明细数组,PUT 为保存结果对象 |
|
||||
| `traceId` | String / null | 链路追踪 ID |
|
||||
| `success` | Boolean | `code=200` 时为 `true` |
|
||||
|
||||
### 5.2 GET `data[]`
|
||||
|
||||
| 字段 | 类型 | 可为空 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `id` | String / null | 是 | 已保存的 19 位行 ID 按字符串返回;未保存的自动生成行可为 `null` |
|
||||
| `sourceType` | String | 否 | 来源类型,取值见 §6.2 |
|
||||
| `sourceTypeName` | String | 是 | 来源类型中文名 |
|
||||
| `scenicAssignmentId` | String / null | 是 | 19 位来源记录 ID 按字符串返回;手动补充行为空 |
|
||||
| `dayNumber` | Integer | 是 | 根据项目日期与订单行程计算的天序 |
|
||||
| `dayDate` | String | 否 | 项目日期,格式为 `yyyy-MM-dd` |
|
||||
| `scenicName` | String | 否 | 景区或游玩项目名称 |
|
||||
| `specName` | String | 否 | 无明确规格时返回“成人票”;已有非空规格原样返回 |
|
||||
| `ticketCount` | Integer | 否 | 实际购票数量 |
|
||||
| `ticketUnitPrice` | Decimal | 是 | 参考单价,单位元 |
|
||||
| `sellPrice` | Decimal | 是 | 客户成交单价,单位元 |
|
||||
| `totalAmount` | Decimal | 是 | 客户成交小计,单位元 |
|
||||
| `plannedCost` | Decimal | 否 | 计划成本,单位元 |
|
||||
| `actualCost` | Decimal | 否 | 实际成本,单位元 |
|
||||
| `paymentMethod` | String | 是 | 付款方式,取值见 §6.3 |
|
||||
| `paymentMethodName` | String | 是 | 付款方式中文名 |
|
||||
| `voucherUrls` | Array\<String> | 是 | 凭证图片 URL 列表 |
|
||||
| `remark` | String | 是 | 备注 |
|
||||
|
||||
### 5.3 PUT `data`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `addedIds` | Array\<String> | 本次新增行 ID 列表 |
|
||||
| `updatedIds` | Array\<String> | 本次更新行 ID 列表 |
|
||||
| `deletedIds` | Array\<String> | 本次删除行 ID 列表 |
|
||||
| `totalActualCost` | String | 保存后实际成本合计,单位元 |
|
||||
|
||||
## 6. 枚举 / 数据字典
|
||||
|
||||
### 6.1 `specName`(数据字典 `settlement_ticket_spec`)
|
||||
|
||||
**所属字段**:`items[].specName` | **类型**:String | **必填**:否
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `成人票` | 成人票 | 当前默认票种/规格;字典接口返回的 `dictValue` |
|
||||
|
||||
加载选项使用:
|
||||
|
||||
```http
|
||||
GET /admin/dict/data/settlement_ticket_spec
|
||||
```
|
||||
|
||||
展示使用字典项 `dictLabel`,提交使用 `dictValue`。本次没有新增 `specCode` 字段。
|
||||
|
||||
### 6.2 `sourceType`
|
||||
|
||||
**所属字段**:`items[].sourceType` | **类型**:String | **必填**:是
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `SCENIC_ASSIGNMENT` | 景区 | 来源于景区项目 |
|
||||
| `ACTIVITY_ASSIGNMENT` | 游玩项目 | 来源于游玩项目 |
|
||||
| `CUSTOM_ASSIGNMENT` | 手动补充 | 核单时手动新增 |
|
||||
|
||||
### 6.3 `paymentMethod`
|
||||
|
||||
**所属字段**:`items[].paymentMethod` | **类型**:String | **必填**:否
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `SIGNED` | 签单 | 供应商签单 |
|
||||
| `COMPANY_PAID` | 公司付款 | 未传付款方式时的默认值 |
|
||||
| `CASH_PAID` | 现付 | 现场付款,可附凭证 |
|
||||
|
||||
## 7. 错误码
|
||||
|
||||
| code | 含义 | 触发场景 |
|
||||
|------|------|----------|
|
||||
| `200` | 成功 | 查询或保存成功 |
|
||||
| `400` | 请求参数校验失败 | 必填字段为空、枚举值不合法、金额为负数、字段超长或日期格式错误 |
|
||||
| `401` | 未认证或认证失效 | 未携带有效管理后台 JWT |
|
||||
| `584011` | 当前核单状态不允许录门票核单 | PUT 时订单核单状态不是“待核单”或“核单中” |
|
||||
| `584017` | 订单缺出发日期 | PUT 时无法根据 `dayDate` 计算 `dayNumber` |
|
||||
| `584018` | 项目日期早于订单出发日期 | PUT 的 `items[].dayDate` 早于订单出发日期 |
|
||||
| `500` | 系统异常 | 查询或保存过程发生未预期异常 |
|
||||
|
||||
## 8. 示例
|
||||
|
||||
### 8.1 典型成功:查询自动生成明细
|
||||
|
||||
**请求**:
|
||||
|
||||
```http
|
||||
GET /v3/admin/order/2079454953641836546/settlement/step2
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": [
|
||||
{
|
||||
"id": null,
|
||||
"sourceType": "SCENIC_ASSIGNMENT",
|
||||
"sourceTypeName": "景区",
|
||||
"scenicAssignmentId": "2079454953641837001",
|
||||
"dayNumber": 1,
|
||||
"dayDate": "2026-07-21",
|
||||
"scenicName": "示例景区",
|
||||
"specName": "成人票",
|
||||
"ticketCount": 2,
|
||||
"ticketUnitPrice": 100.00,
|
||||
"sellPrice": 120.00,
|
||||
"totalAmount": 240.00,
|
||||
"plannedCost": 200.00,
|
||||
"actualCost": 200.00,
|
||||
"paymentMethod": "COMPANY_PAID",
|
||||
"paymentMethodName": "公司付款",
|
||||
"voucherUrls": [],
|
||||
"remark": null
|
||||
}
|
||||
],
|
||||
"traceId": "a1b2c3d4-e5f6-7890",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 典型成功:提交字典选中的“成人票”
|
||||
|
||||
**请求**:
|
||||
|
||||
```http
|
||||
PUT /v3/admin/order/2079454953641836546/settlement/step2
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": null,
|
||||
"sourceType": "SCENIC_ASSIGNMENT",
|
||||
"sourceTypeName": "景区",
|
||||
"scenicAssignmentId": "2079454953641837001",
|
||||
"dayNumber": 1,
|
||||
"dayDate": "2026-07-21",
|
||||
"scenicName": "示例景区",
|
||||
"specName": "成人票",
|
||||
"ticketCount": 2,
|
||||
"ticketUnitPrice": 100.00,
|
||||
"sellPrice": 120.00,
|
||||
"totalAmount": 240.00,
|
||||
"plannedCost": 200.00,
|
||||
"actualCost": 200.00,
|
||||
"paymentMethod": "COMPANY_PAID",
|
||||
"paymentMethodName": "公司付款",
|
||||
"voucherUrls": [],
|
||||
"remark": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"addedIds": ["2079454953641840001"],
|
||||
"updatedIds": [],
|
||||
"deletedIds": [],
|
||||
"totalActualCost": "200.00"
|
||||
},
|
||||
"traceId": "b2c3d4e5-f6a7-8901",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 边界情况:空规格归一为“成人票”
|
||||
|
||||
**保存请求**:
|
||||
|
||||
```http
|
||||
PUT /v3/admin/order/2079454953641836546/settlement/step2
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"sourceType": "ACTIVITY_ASSIGNMENT",
|
||||
"sourceTypeName": "游玩项目",
|
||||
"scenicAssignmentId": "2079454953641837002",
|
||||
"dayDate": "2026-07-22",
|
||||
"scenicName": "示例游玩项目",
|
||||
"specName": null,
|
||||
"ticketCount": 2,
|
||||
"ticketUnitPrice": null,
|
||||
"sellPrice": 0,
|
||||
"totalAmount": 0,
|
||||
"plannedCost": 0,
|
||||
"actualCost": 0,
|
||||
"paymentMethod": "COMPANY_PAID",
|
||||
"voucherUrls": [],
|
||||
"remark": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**保存响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"addedIds": ["2079454953641840002"],
|
||||
"updatedIds": [],
|
||||
"deletedIds": [],
|
||||
"totalActualCost": "0"
|
||||
},
|
||||
"traceId": "c3d4e5f6-a7b8-9012",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
随后 GET 回读时,该行的关键字段为:
|
||||
|
||||
```json
|
||||
{
|
||||
"scenicName": "示例游玩项目",
|
||||
"specName": "成人票"
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 业务失败:非法来源类型
|
||||
|
||||
**请求**:
|
||||
|
||||
```http
|
||||
PUT /v3/admin/order/2079454953641836546/settlement/step2
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"sourceType": "UNKNOWN",
|
||||
"dayDate": "2026-07-21",
|
||||
"scenicName": "示例项目",
|
||||
"specName": "成人票",
|
||||
"ticketCount": 1,
|
||||
"plannedCost": 0,
|
||||
"actualCost": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "sourceType 必须是 SCENIC_ASSIGNMENT / ACTIVITY_ASSIGNMENT / CUSTOM_ASSIGNMENT 之一",
|
||||
"data": null,
|
||||
"traceId": "d4e5f6a7-b8c9-0123",
|
||||
"success": false
|
||||
}
|
||||
```
|
||||
|
||||
## 9. 业务边界
|
||||
|
||||
- GET:自动生成明细没有明确规格时,返回 `specName=成人票`。
|
||||
- GET:历史已保存明细的 `specName` 为 `null`、空串或纯空白时,也返回“成人票”。
|
||||
- GET/PUT:已有非空规格保持不变,例如“骑马体验”不会被覆盖。
|
||||
- PUT:`specName` 最大 128 字符;空值会归一为“成人票”而不是报错。
|
||||
- PUT:`items` 为全量数据;遗漏的旧明细不会继续保留。
|
||||
- PUT:仅订单核单状态为“待核单”或“核单中”时允许保存。
|
||||
- 前端从 `settlement_ticket_spec` 字典读取选项,展示 `dictLabel`,提交 `dictValue` 到 `specName`。
|
||||
|
||||
## 10. 修改前后对比
|
||||
|
||||
### 10.1 字段级对比
|
||||
|
||||
| 字段 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| `items[].specName` | String,可返回或保存为空 | String;查询和保存的空值统一为“成人票” |
|
||||
| `items[].specCode` | 不存在 | 仍不存在,本次未新增 |
|
||||
|
||||
### 10.2 行为级对比
|
||||
|
||||
| 行为 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| 自动生成明细没有明确规格 | `specName` 可能为空或与项目名重复 | `specName=成人票` |
|
||||
| 保存 `specName=null`、空串或纯空白 | 可能按空值保存和回显 | 保存、回读均为“成人票” |
|
||||
| 保存非空自定义规格 | 原样保存 | 仍原样保存 |
|
||||
| 接口数量 | GET、PUT 两个既有接口 | 不变,没有新增 Step2 接口 |
|
||||
|
||||
## 11. 影响评估
|
||||
|
||||
- **是否破坏向后兼容**:否,接口路径、请求结构和响应字段均未改变;只收紧了空规格的返回语义。
|
||||
- **前端是否必须同步上线**:否;前端可逐步接入字典下拉,未接入时也会收到稳定的“成人票”默认值。
|
||||
|
||||
## 12. 注意事项
|
||||
|
||||
- 前端如有 `specName || "成人票"` 的临时兜底,可在确认接口已覆盖当前环境后移除。
|
||||
- 不要新增或提交 `specCode`;当前契约只使用 `specName`。
|
||||
- 选择字典项后提交 `dictValue`,不要提交 `dictLabel` 以外的展示元数据或 `dictDataId`。
|
||||
- 自定义非空规格可以继续提交,接口不会强制替换为字典当前默认项。
|
||||
|
||||
## 13. 关联 / 联系人
|
||||
|
||||
### 13.1 链接
|
||||
|
||||
- **Issue**: [#5185](https://git.1814.love:8443/wx/HL/issues/5185)
|
||||
- **PR**: [#5191](https://git.1814.love:8443/wx/HL/pulls/5191)
|
||||
- **Merge commit**: [03ab19b46](https://git.1814.love:8443/wx/HL/commit/03ab19b463be4b00f92848cb348ce6158918e041)
|
||||
|
||||
### 13.2 联系人
|
||||
|
||||
- **后端负责人**: @yst
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5186"
|
||||
title: "排车中订单恢复派车派人入口并补齐改派上下文"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-23T16:10:00+08:00"
|
||||
---
|
||||
|
||||
# Fleet:排车中订单恢复“派车派人”入口
|
||||
|
||||
> **服务**: hl-fleet-service
|
||||
> **Issue**: #5186
|
||||
> **日期**: 2026-07-23
|
||||
> **影响范围**: 管理后台车务派单看板及订单派车流程
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化
|
||||
|
||||
排车状态为 `holding` 或 `holding_urgent` 时,订单并非不可操作:后端现在返回 `canAssign=true`,并在 `availableActionCodes` 中下发 `CHANGE_ASSIGNMENT`,允许车务继续进入“派车派人”流程调整司机或车辆。
|
||||
|
||||
2026-07-23 契约修订:改派候选查询需要用 `orderId + requirementId + fleetItemIndex` 精确定位当前订单的当前用车需求槽位。看板列表、详情顶层和详情内每个有效派车组现均稳定返回字符串形式的 `requirementId`,前端直接透传,不自行推导。
|
||||
|
||||
## 变更接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 变更类型 |
|
||||
|------|------|------|----------|
|
||||
| 派单看板列表 | GET | `/admin/fleet/board/orders` | 响应字段取值扩展、新增字段 |
|
||||
| 派单看板详情 | GET | `/admin/fleet/board/orders/:orderId` | 新增字段 |
|
||||
|
||||
请求参数和写接口路径均未改变。
|
||||
|
||||
## 二、响应契约
|
||||
|
||||
`records[]` 中以下字段按服务端返回值处理:
|
||||
|
||||
| `assignmentStatus` | `canAssign` | `availableActionCodes` | 前端行为 |
|
||||
|---|---:|---|---|
|
||||
| `unassigned` | `true` | 包含 `ASSIGN` | 展示“派车派人”,提交既有创建派单接口 |
|
||||
| `unassigned_urgent` | `true` | 包含 `ASSIGN` | 展示“派车派人”,提交既有创建派单接口 |
|
||||
| `holding` | `true` | 包含 `CHANGE_ASSIGNMENT` | 展示“改派”,提交既有 `changeAssignment` 改派接口 |
|
||||
| `holding_urgent` | `true` | 包含 `CHANGE_ASSIGNMENT` | 展示“改派”,提交既有 `changeAssignment` 改派接口 |
|
||||
| `assigned` / `completed` / `canceled` | `false` | 不包含上述可派动作 | 不展示入口 |
|
||||
|
||||
前端不要再用 `assignmentStatus === 'unassigned'` 自行推断入口,也不要因为订单已有司机或车辆就隐藏按钮。入口以 `canAssign === true` 为第一判断,具体提交模式以 `availableActionCodes` 为准。
|
||||
|
||||
排车中进入流程属于调整当前有效派单,不是新增第二条有效派单;继续复用现有改派请求、基线差异提示、司机车辆档期冲突提示和刷新逻辑。
|
||||
|
||||
### 改派候选上下文
|
||||
|
||||
| 响应位置 | 新增字段 | 类型 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 列表 `data.records[]` | `requirementId` | `string` | 当前卡片所属用车需求 ID |
|
||||
| 详情 `data` | `requirementId` | `string` | 当前有效用车需求 ID |
|
||||
| 详情 `data.currentAssignment` | `requirementId` | `string` | 当前派车组所属用车需求 ID |
|
||||
| 详情 `data.activeAssignments[]` | `requirementId` | `string` | 每个有效派车组所属用车需求 ID |
|
||||
|
||||
进入改派候选查询时:
|
||||
|
||||
- `orderId` 取列表返回的数字订单 ID;
|
||||
- `requirementId` 优先取详情顶层同名字段,按具体派车组操作时可取该组的同名字段;
|
||||
- `fleetItemIndex` 取当前卡片或当前派车组字段;
|
||||
- 三者必须原样透传给候选接口,不得使用团号、订单号或数组位置替代;
|
||||
- `orderId`、`requirementId` 均按字符串处理,避免 JavaScript 大整数精度丢失。
|
||||
|
||||
当前 `v2.1` 候选请求组装已经读取 `order.requirementId`;后端部署后,从列表进入并合并详情时会获得该字段,无需前端猜测需求 ID。
|
||||
|
||||
### 排车中入口与向导状态
|
||||
|
||||
测试环境现状仍有一处前端状态错位:看板卡片已经显示“排车中”,点击“派车派人”后虽然按 `CHANGE_ASSIGNMENT` 进入 `reassign` 模式,但 `resolveAssignFlowRestoreState(order, mode)` 对所有非 `confirmHold` 模式固定返回 `step: 1`,导致向导错误高亮“订单详情”,底部也显示“下一步 · 排车”。
|
||||
|
||||
前端需要统一按后端状态和动作码恢复入口语义:
|
||||
|
||||
- `assignmentStatus=holding/holding_urgent` 且动作码包含 `CHANGE_ASSIGNMENT` 时,卡片按钮文案显示“改派”,不要继续显示“派车派人”;
|
||||
- 从该入口打开时保持 `mode=reassign`,向导直接进入第 2 步“排车”,第 1 步“订单详情”显示已完成;
|
||||
- 第 2 步带出当前司机、车辆,允许只更换其中一项;提交继续调用既有 `changeAssignment`,不得新增第二条有效派单;
|
||||
- “司机已确认/查看待确认”入口仍使用 `confirmHold` 并恢复第 3 或第 4 步,不能被本次改派逻辑影响;
|
||||
- 首次待派车订单仍从第 1 步开始,按钮仍为“派车派人”。
|
||||
|
||||
以上仅是前端状态机和展示文案调整,后端不新增接口或字段。
|
||||
|
||||
### `holding` 的用户可见状态文案
|
||||
|
||||
`holding` 是后端技术状态码,表示车辆和司机已经锁定、派单通知已经发出,当前正在等待司机回复。面向车务人员时不能继续显示“排车中”,应统一显示为“待确认”:
|
||||
|
||||
- 看板卡片主状态:`holding` 显示“待确认”,`holding_urgent` 显示“待确认即将超时”;
|
||||
- 状态筛选、数量汇总和图例使用同一套“待确认”文案;
|
||||
- 卡片上的司机回执徽标可显示“待回复”,用于补充说明,不能与主状态“排车中”形成两个不同口径;
|
||||
- 技术值仍保持 `holding/holding_urgent`,接口请求参数、状态判断、颜色和改派动作码均不改变;
|
||||
- 首次尚未锁定车辆和司机的 `unassigned` 继续显示“待派车”,确认完成后的 `assigned` 继续显示“已派车”。
|
||||
|
||||
当前 `v2.1` 的 `ORDER_STATUS_META`、`FILTER_STATUS_OPTIONS` 以及后端 `assignmentStatusLabel` 仍含“排车中”旧文案。管理后台应以本节用户口径覆盖展示;如直接消费后端 label,前端需按状态码归一,避免同页出现“排车中”和“待确认”两套名称。
|
||||
|
||||
### 待确认订单的“继续派车”入口
|
||||
|
||||
待确认订单必须同时保留“继续派车”和“改派”两个入口:
|
||||
|
||||
- `availableActionCodes` 包含 `RECORD_DRIVER_CONFIRMATION` 时显示主按钮“继续派车”,点击复用现有 `onConfirmHold(order)`,以 `mode=confirmHold` 打开派单弹窗;
|
||||
- `confirmHold` 根据后端 `stageCode/driverConfirmedAt` 恢复流程:等待司机回复时进入第 3 步“待确认”,已登记司机确认时进入第 4 步“确认执行”;
|
||||
- `availableActionCodes` 包含 `CHANGE_ASSIGNMENT` 时另行显示“改派”,点击进入第 2 步重新选择车辆或司机;
|
||||
- 两个按钮不得互相替代:“继续派车”推进当前有效派单,“改派”修改当前有效派单;
|
||||
- “复制行程单链接”是独立只读能力,复制后端为当前有效派单签发的司机 H5 链接,不参与派单状态流转。
|
||||
|
||||
当前 `v2.1@6e6a11bf` 的 `resolveBoardRowActions()` 仍检查已经废弃的 `CONFIRM` 动作码,而后端生命周期实际下发 `RECORD_DRIVER_CONFIRMATION`,因此截图中“继续派车/司机已确认”按钮没有渲染。前端改为消费真实动作码即可,无需后端增加兼容别名。
|
||||
|
||||
### 看板复制司机 H5 行程单链接
|
||||
|
||||
看板不再维护独立的“发行程单”抽屉,也不在前端模拟发送成功。这里不是打开订单详情的“打印行程单”,而是把后端已经为当前有效派单签发的 H5 链接复制到剪贴板,车务再通过微信等渠道发给司机。司机可在手机浏览器中独立打开,不需要登录管理后台。
|
||||
|
||||
- 将看板按钮文案由“发行程单”改为“复制行程单链接”,事件名同步改为 `copy-itinerary-link`,避免继续表达成“发送”;
|
||||
- 点击时先调用既有看板详情接口 `GET /admin/fleet/board/orders/{orderId}`,不要调用订单打印接口;
|
||||
- 默认复制 `currentAssignment.itineraryUrl`;如果入口明确针对某一条有效派单,则复制对应 `activeAssignments[].itineraryUrl`;
|
||||
- 链接必须直接使用后端返回值,前端不得自行拼接 H5 地址、token、订单 ID 或派单 ID;
|
||||
- 优先使用 `navigator.clipboard.writeText(itineraryUrl)`;当前管理后台可能运行在 HTTP 内网环境,必须同时提供临时 `textarea + document.execCommand('copy')` 降级实现;
|
||||
- 复制成功提示“行程单链接已复制,可发送给司机”;
|
||||
- `itineraryUrl` 为空时不得复制或提示成功,应提示“行程单链接暂不可用,请确认已派车且 H5 配置正常”;
|
||||
- 删除/停用看板自己的 `ItinerarySendSheet.vue`、消息模板和本地 `message.success('已发送行程单')` 假流程;
|
||||
- 不复用 `PrintItineraryModal.vue`,不调用 `GET /v3/admin/order/{orderId}/print-itinerary`;订单详情的“打印行程单”继续作为面向车务的独立打印能力保留;
|
||||
- 链接包含签名 token,前端日志、埋点和错误提示不得记录或展示完整 URL;
|
||||
- 改派成功后必须重新拉取详情并使用新派单的 `itineraryUrl`,不能继续缓存或复制旧派单链接。
|
||||
|
||||
后端链接已绑定当前订单与具体派单,有效期至行程结束后 7 天;公开 H5 接口会校验签名、有效期和派单归属,并实时读取行程数据。复制动作本身不改变派单状态,也不产生“已发送”记录。
|
||||
|
||||
建议前端按以下逻辑落地(函数名可按现有工程调整):
|
||||
|
||||
```ts
|
||||
async function onCopyItineraryLink(order: BoardOrder) {
|
||||
const orderId = resolveBoardOrderId(order)
|
||||
const detail = await getBoardOrderDetail(orderId)
|
||||
const itineraryUrl = detail.currentAssignment?.itineraryUrl?.trim()
|
||||
|
||||
if (!itineraryUrl) {
|
||||
message.error('行程单链接暂不可用,请确认已派车且 H5 配置正常')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(itineraryUrl)
|
||||
} else {
|
||||
copyTextByTextarea(itineraryUrl)
|
||||
}
|
||||
message.success('行程单链接已复制,可发送给司机')
|
||||
} catch {
|
||||
message.error('复制失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
function copyTextByTextarea(text: string) {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
if (!copied) throw new Error('copy failed')
|
||||
}
|
||||
```
|
||||
|
||||
## 三、不影响范围
|
||||
|
||||
- `canRejectRequirement` 仍只在未派阶段可能为 `true`;排车中不得重新开放“驳回用车需求”。
|
||||
- 已派车、已完成、已取消状态不会因本次变更开放派车入口。
|
||||
- 无数据库、Redis、MQ、候选请求字段或错误码变更。
|
||||
|
||||
## 四、前端自测清单
|
||||
|
||||
- [ ] `holding` 订单显示“改派”按钮,点击后带出当前司机、车辆并进入调整流程。
|
||||
- [ ] `holding_urgent` 同样显示“改派”并进入调整流程。
|
||||
- [ ] `holding/holding_urgent + CHANGE_ASSIGNMENT` 卡片按钮显示“改派”,打开后直接高亮第 2 步“排车”,第 1 步为已完成。
|
||||
- [ ] 首次待派车仍从第 1 步开始;`confirmHold` 仍恢复第 3/4 步,三种入口互不串态。
|
||||
- [ ] `holding/holding_urgent` 在卡片、筛选、汇总和图例统一显示“待确认/待确认即将超时”,页面不再出现“排车中”旧文案。
|
||||
- [ ] 技术状态值仍为 `holding`,改派、司机确认和超时判断不因文案变化而改变。
|
||||
- [ ] 待确认订单在 `RECORD_DRIVER_CONFIRMATION` 可用时显示“继续派车”,点击以 `confirmHold` 恢复第 3/4 步。
|
||||
- [ ] “继续派车”和“改派”同时存在且职责分离;前端不再检查不存在的 `CONFIRM` 动作码。
|
||||
- [ ] 待确认或已派车且存在当前有效派单时,看板显示“复制行程单链接”。
|
||||
- [ ] 点击后通过看板详情取得并复制 `currentAssignment.itineraryUrl`,不调用订单打印接口、不在前端拼接链接。
|
||||
- [ ] HTTPS/localhost 使用 Clipboard API,HTTP 内网环境可通过降级方案正常复制。
|
||||
- [ ] 复制成功提示“行程单链接已复制,可发送给司机”;链接为空或复制失败时给出明确错误且不误报成功。
|
||||
- [ ] 复制出的链接可由司机在手机浏览器中独立打开,无需登录管理后台,并展示当前派单的实时行程。
|
||||
- [ ] 看板不再使用 `ItinerarySendSheet` 或模拟“已发送行程单”,改派后不会复制旧派单链接。
|
||||
- [ ] 打开排车步骤时,候选请求同时携带字符串 `orderId`、`requirementId` 和当前 `fleetItemIndex`,不再出现“改派候选查询必须携带当前订单ID、用车需求ID和车型项索引”。
|
||||
- [ ] 提交时调用既有 `changeAssignment`,不调用创建派单接口。
|
||||
- [ ] `assigned`、`completed`、`canceled` 不误显示入口。
|
||||
- [ ] 调整成功后刷新看板,页面只保留一组当前有效派单。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 后端提交:`aaeb01242`;PR:[wx/HL#5190](https://git.1814.love:8443/wx/HL/pulls/5190),已合并 `dev-v3`。
|
||||
- 状态矩阵、生命周期动作、改派上下文及排车中改派保护已有定向测试覆盖;`BoardOrderServiceTest` 与 `BoardControllerTest` 共 49 项通过。
|
||||
- `mvn -f hl-fleet-service/pom.xml spotless:check` 通过。
|
||||
- `mvn -pl hl-fleet-service -am verify` 通过。
|
||||
- 测试环境 Fleet 滚动部署任务 `3458b3ab` 成功,8087、8187 两实例健康。
|
||||
- 网关按团号 `26-7042` 验证:列表与详情 HTTP/code 200,`requirementId` 在列表、详情顶层、`currentAssignment` 和 `activeAssignments[]` 均存在;携带 `orderId + requirementId + fleetItemIndex + excludeAssignmentId` 调用候选接口 HTTP/code 200,返回 19 辆车、19 名司机候选。
|
||||
- 当前前端 `v2.1` 已从 `order.requirementId` 组装候选请求;但截至 `v2.1@6e6a11bf`,`resolveAssignFlowRestoreState` 对 `reassign` 仍固定恢复第 1 步,且排车中入口文案仍为“派车派人”,需要按上方状态规则调整。
|
||||
|
||||
## 六、相关文档
|
||||
|
||||
- [wx/HL#5186](https://git.1814.love:8443/wx/HL/issues/5186)
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5187"
|
||||
title: "多车辆槽位原子批量派车与价格日历带价"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-23T15:38:00+08:00"
|
||||
---
|
||||
|
||||
# 【新增接口·前端待处理·管理后台】多车辆槽位原子批量派车与价格日历带价
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 页面:车务管理 → 派车看板 → 派车派人弹窗、派单详情
|
||||
- 前端交接:仅以本 `hl-api-changelog` 文档为准,不另建前端仓库工单。
|
||||
- 小程序:无需处理
|
||||
|
||||
> **后端工单**: [wx/HL#5187](https://git.1814.love:8443/wx/HL/issues/5187)
|
||||
>
|
||||
> **后端 PR**: [wx/HL#5189](https://git.1814.love:8443/wx/HL/pulls/5189)
|
||||
>
|
||||
> **兼容性**: 既有单槽位 `POST /admin/fleet/assignments` 不变;多车订单必须改用本次批量接口,
|
||||
> 前端不得循环调用单派接口。
|
||||
|
||||
## 一、业务口径
|
||||
|
||||
一条用车需求可能展开出多个车辆槽位。派车弹窗应按 `fleetItemIndex` 维护多组
|
||||
“车辆 + 司机 + 协议价”,允许一次选择多辆车并一次提交。整批任一槽位失败时不得留下前面
|
||||
已成功、后面失败的半批派单。
|
||||
|
||||
- 同一批内 `fleetItemIndex`、`vehicleId`、`driverId` 分别不可重复。
|
||||
- 前端只允许选择当前需求实际展开出的待派槽位,不得自行增加超过需求数量的车辆。
|
||||
- 雪花 ID 全程按字符串保存和提交。
|
||||
- 同一次提交及其网络重试必须复用同一个 `requestId`;用户修改选择后主动再次提交应生成新值。
|
||||
- `holdMode=1` 表示排车中等待司机确认,`holdMode=0` 表示直接派定;整批模式必须一致。
|
||||
|
||||
## 二、新增原子批量派单接口
|
||||
|
||||
```http
|
||||
POST /admin/fleet/assignments/batch
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"orderId": "2046800000000000001",
|
||||
"orderNo": "26-4165",
|
||||
"requirementId": "2046800000000000101",
|
||||
"startDate": "2026-07-28",
|
||||
"endDate": "2026-07-30",
|
||||
"pickupAt": "海拉尔",
|
||||
"dropoffAt": "满洲里",
|
||||
"headcount": 8,
|
||||
"chargeableServiceDates": [
|
||||
"2026-07-28",
|
||||
"2026-07-29",
|
||||
"2026-07-30"
|
||||
],
|
||||
"holdMode": 1,
|
||||
"skipCityJunctionException": false,
|
||||
"fromEntry": "from-board",
|
||||
"requestId": "fleet-batch-7fe5c3a8",
|
||||
"items": [
|
||||
{
|
||||
"fleetItemIndex": 0,
|
||||
"vehicleId": "2046800000000000201",
|
||||
"driverId": "2046800000000000301",
|
||||
"protocolPrice": "520.00",
|
||||
"confirmCrossResident": false
|
||||
},
|
||||
{
|
||||
"fleetItemIndex": 1,
|
||||
"vehicleId": "2046800000000000202",
|
||||
"driverId": "2046800000000000302",
|
||||
"protocolPrice": "860.00",
|
||||
"confirmCrossResident": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 公共字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | ---: | --- |
|
||||
| `orderId` | String(Long) | 是 | 订单 ID |
|
||||
| `orderNo` | String | 否 | 订单号冗余 |
|
||||
| `requirementId` | String(Long) | 是 | 当前生效用车需求 ID |
|
||||
| `startDate` / `endDate` | LocalDate | 是 | 整批服务日期闭区间 |
|
||||
| `pickupAt` / `dropoffAt` | String | 否 | 接送地 |
|
||||
| `headcount` | Integer | 否 | 乘客人数 |
|
||||
| `chargeableServiceDates` | LocalDate[] | 否 | 不传=全部计费;空数组=全部免费 |
|
||||
| `vehicleFeeWaiverReason` | String | 条件必填 | 存在免费服务日时填写 |
|
||||
| `confirmAllServiceDatesFree` | Boolean | 条件必填 | 全部免费时必须为 `true` |
|
||||
| `holdMode` | Integer | 是 | `1=排车中`,`0=直接派定` |
|
||||
| `skipCityJunctionException` | Boolean | 否 | 与单派接口同义 |
|
||||
| `fromEntry` | String | 否 | 操作来源 |
|
||||
| `requestId` | String | 是 | 批次幂等键,最大 64 字符 |
|
||||
| `items` | Object[] | 是 | 1-20 个车辆槽位 |
|
||||
|
||||
### `items[]`
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | ---: | --- |
|
||||
| `fleetItemIndex` | Integer | 是 | 当前需求展开后的槽位序号,0 起 |
|
||||
| `vehicleId` | String(Long) | 是 | 所选车辆 ID,批内不可重复 |
|
||||
| `driverId` | String(Long) | 是 | 所选司机 ID,批内不可重复 |
|
||||
| `protocolPrice` | String(BigDecimal) | 否 | 元/车天;不传时后端按车型价格日历兜底 |
|
||||
| `messageTemplateId` | String(Long) | 否 | `holdMode=1` 的通知模板 |
|
||||
| `customBody` | String | 否 | `holdMode=1` 的本次自定义通知正文 |
|
||||
| `confirmCrossResident` | Boolean | 否 | 跨常驻车辆组合的显式确认 |
|
||||
|
||||
成功响应按 `fleetItemIndex` 升序返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"assignments": [
|
||||
{
|
||||
"fleetItemIndex": 0,
|
||||
"assignment": {
|
||||
"id": "2046800000000000401",
|
||||
"assignmentGroupId": "2046800000000000501",
|
||||
"assignmentSlotId": "2046800000000000601",
|
||||
"assignmentStatus": "holding",
|
||||
"stageCode": "holding_wait_driver",
|
||||
"stageLabel": "排车中·等待司机确认",
|
||||
"currentStep": 2,
|
||||
"skippedStepCodes": [],
|
||||
"protocolPrice": "520.00",
|
||||
"holdSentAt": null,
|
||||
"confirmedAt": null,
|
||||
"sideEffects": null,
|
||||
"dailyDifferences": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedFleetItemIndex": null,
|
||||
"dailyDifferences": null
|
||||
},
|
||||
"message": "成功",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
直接派定发生订单/行程/需求冻结基线不一致时返回既有业务码 `605041`,并额外指出失败槽位:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 605041,
|
||||
"data": {
|
||||
"assignments": [],
|
||||
"failedFleetItemIndex": 1,
|
||||
"dailyDifferences": [
|
||||
{
|
||||
"serviceDate": "2026-07-29",
|
||||
"differenceType": "CAPACITY_INSUFFICIENT",
|
||||
"message": "逐日车辆可用座位不足"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
无论返回哪一种失败,整批均不产生部分成功数据。前端失败后保留用户当前选择并展示后端文案;
|
||||
`605041` 可同时高亮 `failedFleetItemIndex` 对应槽位及逐日差异。
|
||||
|
||||
## 三、派车弹窗前端修改
|
||||
|
||||
### 3.1 多车辆选择
|
||||
|
||||
当前实现只有全局单值 `selVehicle/selDriver`,再次选择会覆盖上一辆车。需改成按
|
||||
`fleetItemIndex` 保存的槽位数组或 Map:
|
||||
|
||||
```text
|
||||
selectedSlots[fleetItemIndex] = {
|
||||
vehicle,
|
||||
driver,
|
||||
protocolPrice,
|
||||
confirmCrossResident
|
||||
}
|
||||
```
|
||||
|
||||
- 点击某个候选车辆只修改当前待选槽位,不清空其他已选槽位。
|
||||
- 已选摘要、取消车辆、司机选择和常驻组合提示均必须作用于对应槽位。
|
||||
- 提交前校验所有本次待派槽位都有车辆和司机,然后一次调用批量接口。
|
||||
- 禁止用 `for` 循环调用旧单派接口;那会在中途失败时留下半批状态。
|
||||
- 成功后一次关闭弹窗并刷新看板;不得每成功一辆刷新一次。
|
||||
|
||||
### 3.2 车型价格日历自动带价
|
||||
|
||||
候选接口 `vehicles[].protocolPrice` 已返回所选车辆车型在服务开始日的价格日历单价。当前页面
|
||||
只从订单级 `props.order.protocolPrice` 初始化输入框,导致价格日历明明有值仍显示空。
|
||||
|
||||
- 选中车辆时,把该车辆的 `protocolPrice` 写入对应槽位价格框。
|
||||
- 每辆车独立显示、独立可编辑,提交到 `items[].protocolPrice`。
|
||||
- 切换车辆时改为新车辆的价格日历值;不能沿用上一辆车的价格。
|
||||
- 候选值为空时输入框可留空,后端仍会在最终保存时按所选车辆车型 + `startDate` 再兜底一次。
|
||||
- 不得把一个全局价格复制给所有不同车型。
|
||||
|
||||
### 3.3 联系定制师
|
||||
|
||||
派车看板卡片已有“联系定制师”,派单详情第 1 步和后续派车弹窗也应与房务详情保持一致:
|
||||
|
||||
- 在详情可见区域补“联系定制师”按钮,复用现有 `open-fleet` 会话流程。
|
||||
- 订单 ID 使用数字雪花字符串,不能传 `HL...` 展示号或团号。
|
||||
- 按钮位置、图标、禁用态、加载态和聊天抽屉交互复用房务模块,不另做一套样式。
|
||||
- 首次打开真实会话和实时未读角标仍按
|
||||
[#5180 前端交接](./23_5180_订单详情联系车务独立未读红点-修改接口-管理后台.md)处理。
|
||||
|
||||
## 四、派车看板默认状态筛选
|
||||
|
||||
这是前端初始化逻辑修复,不需要后端接口变更:
|
||||
|
||||
- `statusSel` 初始值必须为 `[]`,页面首次进入状态框显示空/不限。
|
||||
- 首次列表请求不得携带 `statuses=unassigned`,默认展示全部状态。
|
||||
- 点击“重置”后的值和首次进入完全一致。
|
||||
- 用户主动选择“待派车”后才传对应状态;刷新筛选结果时不得偷偷恢复默认待派车。
|
||||
|
||||
## 五、前端验收清单
|
||||
|
||||
- [ ] 一条需求展开 2 个车辆槽位时,可同时选择 2 辆不同车辆和 2 名不同司机,第一辆不会被第二辆覆盖。
|
||||
- [ ] 提交只发送 1 次 `/admin/fleet/assignments/batch`,不循环调用旧单派接口。
|
||||
- [ ] 第二槽位失败时页面提示失败,刷新后两个槽位都没有半批残留。
|
||||
- [ ] 价格日历有值时,选择每辆车后各自价格框立即带出对应 `protocolPrice`。
|
||||
- [ ] 修改某辆车价格只影响该槽位,成功响应按槽位回显冻结价格。
|
||||
- [ ] 派单详情第 1 步和派车流程均能直接“联系定制师”,交互与房务一致。
|
||||
- [ ] 派车看板首次进入状态筛选为空,首次请求不传 `statuses`,默认可见全部状态。
|
||||
- [ ] 主动筛选“待派车”及重置行为正确。
|
||||
- [ ] 增加多槽位状态管理、批量请求映射、车型切换带价和默认空筛选的组件/组合式函数测试。
|
||||
|
||||
## 六、后端验证证据
|
||||
|
||||
- `mvn -pl hl-fleet-service spotless:check` 通过。
|
||||
- `AssignmentControllerTest + AssignmentServiceTest`:281 项通过。
|
||||
- `mvn -pl hl-fleet-service -am verify` 通过:fleet 2334 项,0 failure / 0 error,1 skipped。
|
||||
- 批量成功、空明细校验、重复槽位校验、字符串雪花 ID、直接派定基线差异及事务/幂等注解均有测试覆盖。
|
||||
- 测试环境部署任务 `28f9048a` 成功,`hl-fleet-service` 两个滚动实例均恢复健康。
|
||||
- 经测试环境网关验证:
|
||||
- 派车看板列表请求返回 HTTP 200 / 业务码 200。
|
||||
- 批量接口空明细返回业务码 400,文案为“派单车辆槽位不能为空”。
|
||||
- 批量接口重复 `fleetItemIndex` 返回业务码 100001,且未产生写入。
|
||||
- 自建并标记测试订单,使用 SUV + MPV 两个槽位执行失败探针:第一槽位合法、第二槽位车辆不存在,
|
||||
接口返回 605001;随后详情仍为 0 个有效派单,证明第一槽位及副作用意图随整批回滚。
|
||||
- 同一测试订单使用两个合法槽位执行成功探针:接口返回 200,结果按
|
||||
`fleetItemIndex=[0,1]` 排序,价格快照分别为 `700.00`、`860.00`,详情恰有 2 个
|
||||
`assigned` 派单。
|
||||
- 使用相同 `requestId` 重放返回业务码 100502;重放后详情仍恰有 2 个有效派单,无重复写入。
|
||||
- 验收后已通过订单取消 API 精确清理自建测试订单,订单状态为 `CANCELLED`,详情有效派单恢复为 0。
|
||||
- 部署后 fleet 服务与网关日志未发现 ERROR;重复槽位探针只产生预期的业务校验 WARN。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5194"
|
||||
title: "待确认详情补全多车多司机与按槽位改派"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-23T18:02:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】待确认详情补全多车多司机与按槽位改派
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-fleet-service
|
||||
>
|
||||
> **工单**: [wx/HL#5194](https://git.1814.love:8443/wx/HL/issues/5194)
|
||||
>
|
||||
> **后端 PR**: [wx/HL#5196](https://git.1814.love:8443/wx/HL/pulls/5196)
|
||||
>
|
||||
> **影响范围**: 派车看板状态、派单弹窗改派、司机待确认页
|
||||
|
||||
## 关键业务口径
|
||||
|
||||
1. `holding` 落库状态不变,但等待司机真实回复时,页面展示文案统一为“待确认”,不得再显示“排车中”。
|
||||
2. 一张订单可以同时存在多个有效车辆槽位,每个槽位有独立车辆、司机和确认阶段。前端必须遍历 `activeAssignments`,不能只读兼容字段 `currentAssignment`。
|
||||
3. 改派以选中的稳定槽位为单位。两辆车中只改一辆时,只提交目标槽位对应的 `activeAssignments[i].id`;其他槽位不取消、不重建、不改变。
|
||||
4. 改派界面必须先展示历史车辆/司机,并要求车务人员在界面中明确清除目标槽位的旧选择后才能选择新车/新司机。
|
||||
5. “清除旧选择”只修改前端草稿状态,**不得先调用取消派单接口**。最终一次调用 `change` 原子替换;用户关闭弹窗时后端原派单保持不变。
|
||||
6. 多车待确认页按槽位分别登记司机回复。只要任一有效 HOLD 槽位未收到司机回复,订单顶部整体步骤仍停在“待确认”。
|
||||
|
||||
## 一、派单详情
|
||||
|
||||
```http
|
||||
GET /admin/fleet/board/orders/{orderId}
|
||||
```
|
||||
|
||||
### `activeAssignments[]` 完整字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `String` | 当前有效派车组锚点 ID;改派、登记司机确认、最终确认均使用该值 |
|
||||
| `assignmentGroupId` | `String` | 当前派车组 ID;改派后会生成新值 |
|
||||
| `assignmentSlotId` | `String` | 稳定车辆槽位 ID;同一槽位改派前后保持不变 |
|
||||
| `fleetItemIndex` | `Integer` | 用车需求项序号 |
|
||||
| `requiredVehicleType` | `String` | 需求车型 |
|
||||
| `requiredSeats` | `Integer` | 需求座位数 |
|
||||
| `vehicleId` | `String` | 当前车辆 ID |
|
||||
| `vehiclePlate` | `String` | 当前车牌 |
|
||||
| `vehicleModel` | `String` | 当前车型;优先派车冻结快照,缺失时回填车辆档案 |
|
||||
| `vehicleSeats` | `Integer` | 当前车辆座位数 |
|
||||
| `vehicleFleetTeamId` | `String` | 当前车辆所属车队 ID |
|
||||
| `vehicleFleetTeamName` | `String` | 当前车辆所属车队名称 |
|
||||
| `driverId` | `String` | 当前司机 ID |
|
||||
| `driverName` | `String` | 当前司机姓名 |
|
||||
| `driverPhone` | `String` | 当前司机脱敏手机号,例如 `138****1234` |
|
||||
| `assignmentStatus` | `String` | 派生态 |
|
||||
| `assignmentStatusLabel` | `String` | 后端统一展示文案 |
|
||||
| `lifecycleStageCode` | `String` | 当前槽位生命周期阶段 |
|
||||
| `lifecycleStageLabel` | `String` | 当前槽位阶段文案 |
|
||||
| `currentStep` | `Integer` | 当前槽位所在步骤 |
|
||||
| `availableActionCodes` | `String[]` | 当前槽位允许操作 |
|
||||
| `driverConfirmedAt` | `LocalDateTime/null` | 本槽位司机确认时间 |
|
||||
|
||||
示例(字段已脱敏):
|
||||
|
||||
```json
|
||||
{
|
||||
"currentAssignment": {
|
||||
"id": "2079502431745396738",
|
||||
"assignmentSlotId": "2079502431745396738"
|
||||
},
|
||||
"activeAssignments": [
|
||||
{
|
||||
"id": "2079502431745396738",
|
||||
"assignmentGroupId": "2079502431745396738",
|
||||
"assignmentSlotId": "2079502431745396738",
|
||||
"fleetItemIndex": 0,
|
||||
"vehicleId": "2079857985374363650",
|
||||
"vehiclePlate": "蒙A-T1557",
|
||||
"vehicleModel": "丰田普拉多",
|
||||
"vehicleSeats": 7,
|
||||
"vehicleFleetTeamId": "2079857981112934401",
|
||||
"vehicleFleetTeamName": "合作车队A",
|
||||
"driverId": "2079857983403024385",
|
||||
"driverName": "司机姓名",
|
||||
"driverPhone": "199****1557",
|
||||
"baseAssignmentStatus": "holding",
|
||||
"assignmentStatus": "holding",
|
||||
"assignmentStatusLabel": "待确认",
|
||||
"lifecycleStageCode": "holding_wait_driver",
|
||||
"lifecycleStageLabel": "待确认",
|
||||
"currentStep": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 兼容与聚合规则
|
||||
|
||||
- `currentAssignment` 仍返回“最新有效派车组”,仅用于兼容旧版单车页面;新页面不得据此判断订单只有一辆车。
|
||||
- `activeAssignments` 只含有效 `holding/assigned` 派车组,按需求项、服务日期、派车组 ID 稳定排序。
|
||||
- `progressSteps` 是订单整体步骤,多车时按最慢有效槽位聚合。
|
||||
- 每辆车的实际阶段以对应 `activeAssignments[i].lifecycleStageCode/currentStep` 为准。
|
||||
- 老异常数据若车辆档案或司机电话确实缺失,对应字段可能为 `null`;页面显示 `-`,不得导致整页报错。
|
||||
|
||||
## 二、按目标槽位改派
|
||||
|
||||
```http
|
||||
POST /admin/fleet/assignments/{assignmentId}/change
|
||||
```
|
||||
|
||||
`assignmentId` 必须使用车务人员选中的 `activeAssignments[i].id`,不要使用订单 ID,也不要默认使用 `currentAssignment.id`。
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"effectiveDate": "2026-07-29",
|
||||
"newVehicleId": "2079857985374363999",
|
||||
"newDriverId": "2079857983403024999",
|
||||
"holdMode": 1,
|
||||
"messageTemplateId": "2073978002412105729",
|
||||
"protocolPrice": 700.00,
|
||||
"reason": "替换第 2 个车辆槽位",
|
||||
"requestId": "change-slot-20260723-001"
|
||||
}
|
||||
```
|
||||
|
||||
原子替换成功响应会明确返回稳定槽位和其他未改车辆:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"assignmentId": "新的有效派单锚点ID",
|
||||
"assignmentSlotId": "改派前后不变的槽位ID",
|
||||
"previousAssignmentGroupId": "被替换的旧派车组ID",
|
||||
"newAssignmentGroupId": "新派车组ID",
|
||||
"assignmentStatus": "holding",
|
||||
"effectiveDate": "2026-07-29",
|
||||
"affectedDays": 3,
|
||||
"otherVehicleCount": 1,
|
||||
"warningCode": "ORDER_HAS_OTHER_VEHICLES",
|
||||
"otherVehicles": [
|
||||
{
|
||||
"assignmentSlotId": "未改车辆槽位ID",
|
||||
"vehiclePlate": "蒙A-U1557",
|
||||
"driverName": "另一位司机",
|
||||
"startDate": "2026-07-29",
|
||||
"endDate": "2026-07-31"
|
||||
}
|
||||
]
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### 改派页面正确流程
|
||||
|
||||
1. 打开改派时用 `activeAssignments` 渲染全部现有槽位卡片,显示车牌、车型、座位、司机姓名、脱敏电话。
|
||||
2. 用户先选择要替换的槽位;两车订单不得自动选“最新一辆”代替用户决定。
|
||||
3. 目标槽位显示“清除当前车辆/司机”。用户明确点击后,只清空本地候选草稿并解锁新车/新司机选择。
|
||||
4. 未清除目标槽位前禁用候选选择和提交;其他槽位仍只读展示,不跟随清空。
|
||||
5. 提交时只调用一次目标 `id` 的 `change`。禁止先 `DELETE /assignments/{id}`,也禁止先调用 `driver-reject`。
|
||||
6. 成功后重新请求订单详情,用新的 `activeAssignments` 替换页面状态;不要在前端自行拼接新旧派车组。
|
||||
7. `warningCode=ORDER_HAS_OTHER_VEHICLES` 是“还有其他车辆保持不变”的强提示,不是失败,不得继续批量改派其他槽位。
|
||||
|
||||
## 三、多司机待确认页
|
||||
|
||||
司机待确认页必须遍历 `activeAssignments`,每个槽位至少显示:
|
||||
|
||||
- 车型、车牌、座位数;
|
||||
- 司机姓名、脱敏手机号;
|
||||
- 当前阶段文案;
|
||||
- 本槽位的通知模板预览、司机回复摘要、确认凭证和操作按钮。
|
||||
|
||||
每名司机独立调用:
|
||||
|
||||
```http
|
||||
POST /admin/fleet/assignments/{activeAssignments[i].id}/driver-confirmation
|
||||
POST /admin/fleet/assignments/{activeAssignments[i].id}/confirm
|
||||
```
|
||||
|
||||
不得因为其中一名司机已确认,就把其他仍待回复的司机一起标记为已确认。
|
||||
|
||||
## 四、模板渲染的 `canceled` 处理
|
||||
|
||||
测试网关已验证真实 `hold_notify` 模板列表和 `render` 接口均为 200,订单 26-7042 的模板正文可正常渲染。页面出现原样英文 `canceled` 是前端取消旧请求被当成业务错误展示,不是后端模板错误。
|
||||
|
||||
前端必须:
|
||||
|
||||
1. 模板、车辆或司机快速切换时允许取消旧 render 请求。
|
||||
2. 对 Axios `CanceledError`、`ERR_CANCELED` 或项目统一的取消请求判定静默处理,不弹错误条、不清空最后一次成功预览。
|
||||
3. 只采纳最新一次请求的响应;较早请求即使后返回也不得覆盖新预览。
|
||||
4. 真正的 HTTP/业务错误才显示“模板加载失败”,并保留“重试加载”。
|
||||
5. 禁止把异常对象的 `message`(例如 `canceled`)直接展示给用户。
|
||||
|
||||
## 五、前端处理清单
|
||||
|
||||
- [ ] 看板卡片对 `holding_wait_driver` 展示“待确认”,不再硬编码“排车中”。
|
||||
- [ ] 派单详情、改派和待确认页全部遍历 `activeAssignments`,不再只读 `currentAssignment`。
|
||||
- [ ] 每个槽位显示车型、车牌、座位、司机姓名和脱敏手机号。
|
||||
- [ ] 多车顶部步骤使用后端 `progressSteps`,每车状态使用本项生命周期字段。
|
||||
- [ ] 改派前展示全部现有槽位,由车务人员明确选择目标槽位。
|
||||
- [ ] 目标槽位必须先在界面中手动清除旧选择,才允许重新选择车辆/司机。
|
||||
- [ ] 清除操作仅修改前端草稿,不调用取消或退回接口;最终只调用一次目标槽位的 `change`。
|
||||
- [ ] 两车只换一车时,另一槽位保持原样;成功后重新拉取详情。
|
||||
- [ ] 每名司机分别登记确认和最终确认,不能用一个槽位状态覆盖全部司机。
|
||||
- [ ] render 请求取消时静默处理,不再显示原样英文 `canceled`。
|
||||
|
||||
## 六、验证证据
|
||||
|
||||
- 后端:fleet 及依赖模块全量 `verify` 成功,2,336 个测试 0 失败、1 个既有跳过;`spotless:check` 通过。
|
||||
- 按槽位改派测试:验证替换目标槽位时不会调用其他槽位的取消语句。
|
||||
- 多车测试:验证完整返回两个稳定槽位及车辆/司机字段;任一司机未回复时整体仍停在待确认。
|
||||
- 部署:测试环境部署任务 `6679d69c` 成功,8087/8187 双实例滚动发布并通过健康检查。
|
||||
- 网关:订单 26-7042 返回 `assignmentStatus=holding`、`assignmentStatusLabel=待确认`、`lifecycleStageCode=holding_wait_driver`、`lifecycleStageLabel=待确认`。
|
||||
- 网关:订单 26-7042 的 `activeAssignments` 已返回稳定槽位、车型、座位、司机和脱敏手机号;真实模板 render 正文长度 240,未出现后端 `canceled` 错误。
|
||||
- 当前测试环境看板前 99 个唯一订单没有多有效派车样本,因此多车网关展示不能靠现存业务数据复验,后端多车契约由自动化测试覆盖。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5199"
|
||||
title: "车务首页订单去重与字段补全"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-24T09:46:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】车务首页订单去重与字段补全
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-fleet-service、hl-user-service
|
||||
>
|
||||
> **工单**: [wx/HL#5199](https://git.1814.love:8443/wx/HL/issues/5199)
|
||||
>
|
||||
> **影响范围**: 车务角色首页的待安排车辆统计、即将用车订单表格和操作列
|
||||
|
||||
## 关键变化
|
||||
|
||||
`GET /admin/profile/dashboard?period=today` 在当前角色为 `VEHICLE_MANAGER` 时,`data.upcomingTrips` 的口径调整为:
|
||||
|
||||
- 只返回近 7 天仍存在未完成派车槽位的订单,未完成态为 `unassigned/unassigned_urgent/holding/holding_urgent`;
|
||||
- 同一订单的多个车型、车辆或派车槽位按 `orderId` 合并为一行;
|
||||
- 返回符合条件的完整订单集合,不再固定截断为 5 条;
|
||||
- 全部派车槽位均已进入 `assigned` 的订单不再出现在待处理列表。
|
||||
|
||||
派车看板 `/admin/fleet/board/orders` 仍保持派车槽位维度和原分页规则,本次不修改。
|
||||
|
||||
## 响应字段
|
||||
|
||||
`data.upcomingTrips[]` 完整结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"orderId": "70123456789",
|
||||
"assignmentId": "80123456789",
|
||||
"orderNo": "HL202607010001",
|
||||
"teamNo": "26-7218",
|
||||
"productName": "呼伦贝尔 6 日",
|
||||
"customerName": "张先生",
|
||||
"contactName": "张先生",
|
||||
"plannerName": "苏日娜",
|
||||
"consultantName": "苏日娜",
|
||||
"consultantDisplayName": "苏日娜",
|
||||
"departureDate": "2026-07-10",
|
||||
"endDate": "2026-07-15",
|
||||
"headcount": 4,
|
||||
"status": "unassigned_urgent",
|
||||
"statusLabel": "待派车",
|
||||
"urgentBadge": "T-1",
|
||||
"canAssign": true,
|
||||
"canRejectRequirement": true
|
||||
}
|
||||
```
|
||||
|
||||
本次补齐的字段:
|
||||
|
||||
| 字段 | 类型 | 页面用途 |
|
||||
| --- | --- | --- |
|
||||
| `teamNo` | `String/null` | 团号列 |
|
||||
| `contactName` | `String/null` | 联系人列,当前与 `customerName` 同值 |
|
||||
| `plannerName` | `String/null` | 定制师兼容字段 |
|
||||
| `consultantName` | `String/null` | 定制师兼容字段 |
|
||||
| `consultantDisplayName` | `String/null` | 定制师优先展示字段 |
|
||||
| `canAssign` | `Boolean` | 是否显示派车/调整入口 |
|
||||
| `canRejectRequirement` | `Boolean` | 是否显示驳回需求入口 |
|
||||
|
||||
`orderId` 和 `assignmentId` 继续按字符串处理,不得转为 JavaScript `Number`。同订单存在多个未完成派车槽位时,`assignmentId/status/statusLabel/urgentBadge/操作权限` 来自后端排序最靠前的代表槽位。
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] 表格行以 `orderId` 为订单级唯一键,不按 `assignmentId` 重复渲染。
|
||||
- [ ] 不在前端截取前 5 条,也不再自行做派车槽位去重。
|
||||
- [ ] 团号列读取 `teamNo`。
|
||||
- [ ] 定制师列优先读取 `consultantDisplayName`,为空时可回退 `consultantName/plannerName`。
|
||||
- [ ] 操作列按 `canAssign/canRejectRequirement` 显示已有操作入口,不根据中文状态文案推断。
|
||||
- [ ] 覆盖同订单多车型、多车、超过 5 个未完成订单、部分槽位已派和全部槽位已派场景。
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- 不修改 `pendingArrangeVehicle` 字段名。
|
||||
- 不修改派车看板、矩阵派单、车辆列表或司机列表的分页和展示维度。
|
||||
- 不新增前端路由、权限码或请求参数。
|
||||
|
||||
## 后端验证
|
||||
|
||||
- 后端 PR:[wx/HL#5201](https://git.1814.love:8443/wx/HL/pulls/5201)。
|
||||
- `FleetDashboardSummaryServiceTest` 覆盖 8 条派车槽位聚合为 6 个未完成订单、重复订单去重和 `assigned` 过滤。
|
||||
- `LogisticsDashboardServiceTest` 覆盖团号、联系人、定制师与操作权限字段透传及 Feign 降级空态。
|
||||
- `hl-fleet-service` 测试环境滚动部署任务 `0944b306` 成功,8087/8187 双实例健康。
|
||||
- `hl-user-service` 测试环境滚动部署任务 `e306a054` 成功,8081/8181 双实例健康。
|
||||
- PR 合并后以 `dev-v3` 再次滚动部署,fleet 任务 `d914f3fb`、user 任务 `3fde7f12` 均成功,
|
||||
部署仓库 HEAD 包含合并提交 `bab22a096d`。
|
||||
- 经网关请求 `GET /admin/profile/dashboard?period=today` 返回 HTTP 200、业务码 200;`upcomingTrips`
|
||||
与相同日期和状态条件下的派单看板订单集合一致,共 4 个唯一订单,重复订单数 0,无已完成订单混入。
|
||||
- `teamNo/contactName/plannerName/consultantName/consultantDisplayName/canAssign/canRejectRequirement`
|
||||
在 4 条记录中均存在且均有有效值;user-service 与 gateway 近期日志未发现本次调用异常。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5200"
|
||||
title: "用车需求驳回历史与重新提交"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-24T11:05:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】用车需求驳回历史与重新提交
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-order-service-v3、hl-fleet-service
|
||||
>
|
||||
> **工单**: [wx/HL#5200](https://git.1814.love:8443/wx/HL/issues/5200)
|
||||
>
|
||||
> **影响范围**: 订单详情行程安排中的用车需求、驳回后的重新提交
|
||||
|
||||
## 业务口径
|
||||
|
||||
- 用车需求被车务驳回后,旧版本终态失活并作为只读历史保留,不是“已回配”。
|
||||
- 驳回后没有当前 active 用车需求,订单详情允许定制师重新提交。
|
||||
- 重新提交创建新的 active 版本并重新进入车务流程,不复活或覆盖旧版本。
|
||||
- 新旧版本可以同屏展示:当前版本保留原有操作,历史版本只读。
|
||||
|
||||
## 一、订单详情行程安排
|
||||
|
||||
接口:
|
||||
|
||||
```http
|
||||
GET /v3/admin/order/{orderId}/itinerary
|
||||
```
|
||||
|
||||
响应 `data` 新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"vehicleGroup": null,
|
||||
"vehicleHistory": [
|
||||
{
|
||||
"requirementId": "2080186927616606210",
|
||||
"version": 1,
|
||||
"status": "REJECTED_TO_CONSULTANT",
|
||||
"isActive": false,
|
||||
"submittedAt": "2026-07-23 15:03:06",
|
||||
"returnedAt": "2026-07-24 09:20:00",
|
||||
"returnRemark": "当地无合适车辆",
|
||||
"vehicleTypeSummary": "suv×1",
|
||||
"specialTags": ["儿童安全座椅", "大行李空间"],
|
||||
"pickupRequired": true,
|
||||
"dropoffRequired": true,
|
||||
"remark": "原需求备注"
|
||||
}
|
||||
],
|
||||
"canContactFleet": false,
|
||||
"contactFleetDisabledReason": "请先提交有效用车需求后再联系车务"
|
||||
}
|
||||
```
|
||||
|
||||
### `vehicleHistory` 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `requirementId` | `String` | 历史需求 ID |
|
||||
| `version` | `Integer` | 版本号,列表按版本倒序 |
|
||||
| `status` | `String` | 驳回场景为 `REJECTED_TO_CONSULTANT` 或 `REJECTED_TO_ADMIN` |
|
||||
| `isActive` | `Boolean` | 历史项固定为 `false` |
|
||||
| `submittedAt` | `LocalDateTime` | 原需求提交时间 |
|
||||
| `returnedAt` | `LocalDateTime/null` | 驳回时间 |
|
||||
| `returnRemark` | `String/null` | 驳回原因 |
|
||||
| 其余摘要字段 | 与 `vehicleGroup.requirement` 相同 | 车型、座位、接送、特殊诉求和备注等原需求快照 |
|
||||
|
||||
边界行为:
|
||||
|
||||
- 从未提交用车需求:`vehicleGroup=null`、`vehicleHistory=[]`。
|
||||
- 已驳回且尚未重提:`vehicleGroup=null`、`vehicleHistory` 包含驳回历史。
|
||||
- 已重新提交:`vehicleGroup.requirement` 是新 active 版本,`vehicleHistory` 仍包含旧版本。
|
||||
- 历史列表还可能包含被正常重版替换的失活版本,前端按 `status` 决定是否展示“已驳回”。
|
||||
|
||||
## 二、重新提交
|
||||
|
||||
继续使用现有接口:
|
||||
|
||||
```http
|
||||
PUT /v3/admin/order/{orderId}/vehicle-requirement
|
||||
```
|
||||
|
||||
驳回后提交的返回语义:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"version": 2,
|
||||
"isActive": true,
|
||||
"status": "PENDING",
|
||||
"branchTaken": "INIT_SUBMIT"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
版本规则:
|
||||
|
||||
- 新版本号 = 历史最高版本号 + 1;
|
||||
- 新版本为当前 active 需求;
|
||||
- 旧驳回版本继续留在 `vehicleHistory`;
|
||||
- 仅新版本进入车务看板和派单流程。
|
||||
|
||||
## 三、前端处理清单
|
||||
|
||||
- [ ] 订单详情用车卡片固定支持“历史需求”只读区域,不论当前 active 需求是否存在。
|
||||
- [ ] 历史项展示版本、原需求内容、驳回状态、`returnedAt` 和 `returnRemark`。
|
||||
- [ ] 历史项不得显示修改、联系车务、派车或“已回配”等当前需求操作/文案。
|
||||
- [ ] `vehicleGroup=null` 且 `vehicleHistory` 非空时,继续显示“提交用车需求”入口。
|
||||
- [ ] 有新 `vehicleGroup.requirement` 时,同时展示当前需求和旧历史,历史项不覆盖当前状态。
|
||||
- [ ] 不要把 `vehicleHistory` 项映射成当前 `vehicleGroup.requirement`。
|
||||
- [ ] 覆盖驳回未重提、驳回后重提、存在多个历史版本三个场景。
|
||||
|
||||
## 四、兼容说明
|
||||
|
||||
现有前端在 `vehicleGroup=null` 时已能进入“提交用车需求”空态,因此后端部署后不会再把驳回需求误显示成“已回配”。新增历史区域需要前端按上方清单接入;未接入时只是暂不展示历史内容,不影响重新提交。
|
||||
|
||||
## 五、后端验证
|
||||
|
||||
- 驳回 CAS 原子更新 `status`、`is_active=false`、驳回原因/时间并清空接单人。
|
||||
- Fleet Outbox 按“订单需求驳回成功 → 取消未派占位”的顺序执行;重放已失活驳回历史时幂等成功。
|
||||
- 无 active 需求时按历史最高版本递增,兼容旧 active rejected 数据。
|
||||
- 订单/Fleet 相关定向测试累计 658 项通过。
|
||||
- `mvn -pl hl-order-service-v3 -am verify`、`mvn -pl hl-fleet-service -am verify` 和 fleet `spotless:check` 全部通过。
|
||||
- 后端 PR [wx/HL#5206](https://git.1814.love:8443/wx/HL/pulls/5206) 已合并到
|
||||
`dev-v3@c13a035d0`;测试环境滚动部署任务 `a2a5a9fa` 成功,8086/8186 两实例健康。
|
||||
- 网关以 `wx` 验证订单 `26-9919`:驳回迁移后 `vehicleGroup=null`,
|
||||
`vehicleHistory` 返回 v1、`REJECTED_TO_CONSULTANT`、`isActive=false` 及原驳回原因;
|
||||
重新提交返回 v2、`PENDING`,再次查询同时保留 v1 历史和 v2 当前需求。
|
||||
- 测试库核对:v1 已失活且保留,v2 为唯一 active;Fleet 为 v2 生成 6 条
|
||||
`unassigned` 日切片,旧 v1 的 6 条派单保持 `canceled`。
|
||||
- 异常团号 `26-4165` 的 3 条旧派单均已取消并软删除,0 条可见、0 条在途;
|
||||
对应失败 Outbox 已进入 `QUARANTINED`,网关看板按团号搜索返回 0 条。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5205"
|
||||
title: "车务首页汇总与看板人员类型展示"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-24T10:45:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】车务首页汇总与看板人员类型展示
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-fleet-service、hl-user-service
|
||||
>
|
||||
> **工单**: [wx/HL#5205](https://git.1814.love:8443/wx/HL/issues/5205)
|
||||
>
|
||||
> **影响范围**: 车务首页待安排车辆卡片、即将用车订单状态标签、派单看板人数摘要
|
||||
|
||||
## 1. 首页待安排车辆汇总
|
||||
|
||||
`GET /admin/profile/dashboard?period=today` 的响应结构不变,`data.pendingArrangeVehicle`
|
||||
调整为 `data.upcomingTrips` 中订单级唯一的有效未完成配车订单总数。
|
||||
|
||||
计入口径:
|
||||
|
||||
- `unassigned`、`unassigned_urgent`:待派车;
|
||||
- `holding`、`holding_urgent`:待确认。
|
||||
|
||||
不计入口径:
|
||||
|
||||
- `assigned`:已完成车辆配置;
|
||||
- `canceled`、`completed`:已取消或已完结,不属于有效待处理订单。
|
||||
|
||||
页面不得只统计 `unassigned`,也不得自行按派车槽位累加。当前测试环境验收样例为
|
||||
3 个待派车加 1 个待确认,`pendingArrangeVehicle` 与列表徽标都应显示 4。
|
||||
|
||||
## 2. 首页状态标签颜色
|
||||
|
||||
颜色按稳定状态码映射,不按中文 `statusLabel` 判断:
|
||||
|
||||
| `upcomingTrips[].status` | 标签 | 建议语义色 |
|
||||
| --- | --- | --- |
|
||||
| `unassigned` / `unassigned_urgent` | 待派车 | warning / 橙色 |
|
||||
| `holding` / `holding_urgent` | 待确认 | processing / 蓝色 |
|
||||
|
||||
紧急程度继续使用 `urgentBadge` 单独表达,不要通过把全部状态渲染成橙色来表示紧急。
|
||||
|
||||
## 3. 派单看板人员类型
|
||||
|
||||
`GET /admin/fleet/board/orders` 的 `data.records[]` 已返回真实订单人数构成:
|
||||
|
||||
```json
|
||||
{
|
||||
"headcount": 5,
|
||||
"adultCount": 2,
|
||||
"childCount": 1,
|
||||
"youngChildCount": 1,
|
||||
"babyCount": 1
|
||||
}
|
||||
```
|
||||
|
||||
看板卡片不要只展示 `5人`,应展示人员类型构成,例如:
|
||||
|
||||
```text
|
||||
成人2 · 儿童1 · 幼童1 · 婴儿1
|
||||
```
|
||||
|
||||
展示规则:
|
||||
|
||||
- 四类字段均为订单真实数据,不得按客户名、标签、总人数或订单 ID 推测;
|
||||
- 数值为 0 的类型可省略;
|
||||
- 四类字段全部为 `null` 时才兼容回退 `headcount + "人"`;
|
||||
- 四类人数合计与 `headcount` 不一致时保留后端原值,并上报数据异常,不在前端静默改数。
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] 首页待安排车辆卡片直接展示后端 `pendingArrangeVehicle`,不再自行只统计待派车状态。
|
||||
- [ ] 首页列表徽标与 `upcomingTrips.length` 保持一致。
|
||||
- [ ] 待派车使用橙色,待确认使用蓝色;颜色映射使用状态码。
|
||||
- [ ] 派单看板用四类人数构成替换单一总人数文案,零值类型省略。
|
||||
- [ ] 覆盖 3 个待派车 + 1 个待确认、紧急派生态、四类人数混合和全零/空值兼容场景。
|
||||
|
||||
## 后端验证
|
||||
|
||||
- `FleetDashboardSummaryServiceTest` 覆盖待派车与待确认共同汇总、订单级去重及完成态排除。
|
||||
- 分支 `fix/5205-fleet-dashboard-summary` 已通过测试环境滚动部署任务 `f64ec5ef`,
|
||||
`hl-fleet-service` 的 8087/8187 双实例健康。
|
||||
- 2026-07-24 经测试网关验证:
|
||||
`pendingArrangeVehicle=4`、`upcomingTrips.length=4`、唯一订单数为 4、重复数为 0,
|
||||
状态分布为 `unassigned:3`、`holding:1`。
|
||||
- `GET /admin/fleet/board/orders` 返回 9 条记录,9 条均具有非空的
|
||||
`adultCount/childCount/youngChildCount/babyCount`。
|
||||
- fleet、user 与 gateway 日志未发现本次 dashboard 请求相关异常。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5209"
|
||||
title: "出行人省份与分批大交通关联"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "implemented"
|
||||
frontend_status: "implemented"
|
||||
frontend_owner: "hl-ui-codex"
|
||||
frontend_ref: "mmg/hl-ui@4424375ef9180e69f22a4f5f6b0b80c9ec2062b7"
|
||||
updated_at: "2026-07-24T07:15:16.554Z"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-24T11:58:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】出行人省份与分批大交通关联
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-order-service-v3、hl-fleet-service
|
||||
>
|
||||
> **工单**: [wx/HL#5209](https://git.1814.love:8443/wx/HL/issues/5209)
|
||||
>
|
||||
> **PR**: [wx/HL#5212](https://git.1814.love:8443/wx/HL/pulls/5212)
|
||||
>
|
||||
> **影响范围**: 车务派单详情的出行人省份标签、大交通与出行人关联、分批抵达/离开展示
|
||||
|
||||
## 一、接口变化
|
||||
|
||||
`GET /admin/fleet/board/orders/{orderId}`
|
||||
|
||||
### 1. 出行人
|
||||
|
||||
`data.travelers[]` 补充省级行政区,并继续返回稳定的大交通计划 ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"travelerId": "3001",
|
||||
"nameMasked": "张**",
|
||||
"idNoMasked": "320***********108X",
|
||||
"idProvinceCode": "32",
|
||||
"idProvinceName": "江苏省",
|
||||
"transportPlanIds": ["4001", "4003"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `idProvinceCode` | `String \| null` | 身份证省级行政区代码,例如 `32` |
|
||||
| `idProvinceName` | `String \| null` | 身份证省级行政区名称,例如 `江苏省` |
|
||||
| `transportPlanIds` | `String[]` | 该出行人关联的全部大交通计划 ID |
|
||||
|
||||
省份仅对结构、生日段和省级前缀均可识别的 18 位大陆身份证派生。护照、其他证件、空值或无法识别的身份证返回 `null`;接口不会新增身份证明文。
|
||||
|
||||
### 2. 大交通单段和批次
|
||||
|
||||
`data.transport.arrive`、`data.transport.depart` 及 `data.transport.batches[]` 统一补充:
|
||||
|
||||
```json
|
||||
{
|
||||
"planId": "4001",
|
||||
"direction": "ARRIVAL",
|
||||
"travelerIds": ["3001"],
|
||||
"transportNo": "CA1234",
|
||||
"time": "2026-07-29T11:10:00",
|
||||
"station": "满洲里西郊机场",
|
||||
"pickupRequired": true
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `planId` | `String` | 大交通计划 ID |
|
||||
| `direction` | `String \| null` | `ARRIVAL` 抵达、`DEPARTURE` 离开;极少量历史异常数据可能为 `null` |
|
||||
| `travelerIds` | `String[]` | 本段或本批关联的出行人 ID |
|
||||
| `pickupRequired` | `Boolean \| null` | 本段或本批是否需要平台接送 |
|
||||
|
||||
一起抵达/离开的首段仍放在 `arrive` 或 `depart`。同方向存在更多批次时,后续批次保留在 `batches[]`,不会合并为单个时间或站点。
|
||||
|
||||
## 二、稳定关联算法
|
||||
|
||||
前端必须按 ID 关联,不再按姓名关联:
|
||||
|
||||
1. 将 `travelers[]` 按 `String(travelerId)` 建立索引。
|
||||
2. 将非空的 `transport.arrive`、`transport.depart` 与 `transport.batches[]` 合并为大交通段列表。
|
||||
3. 对每个大交通段遍历 `travelerIds[]`,按字符串 ID 查找对应出行人。
|
||||
4. 需要反向查询时,用出行人的 `transportPlanIds[]` 匹配各段 `planId`。
|
||||
|
||||
所有雪花 ID 都按 JSON 字符串返回。不要转换为 JavaScript `Number`,避免精度丢失。
|
||||
|
||||
`travelerNames` 仅为旧页面兼容展示字段,不是关联键。姓名可能重复、脱敏或变化,不得用于匹配。
|
||||
|
||||
## 三、页面处理
|
||||
|
||||
- 出行人卡片在 `idProvinceName` 非空时显示省份标签;为空时不显示占位标签。
|
||||
- 大交通区域按 `direction` 区分抵达和离开,不根据数组位置猜方向。
|
||||
- 每个批次独立展示时间、站点、班次、接送要求和对应出行人。
|
||||
- 同方向多个批次不得覆盖、去重或压缩为一个批次。
|
||||
- `travelerIds` 为空时显示该交通段,但不要按姓名猜测关联人。
|
||||
- `direction=null` 的历史记录可显示为“方向待完善”,不要默认当作离开。
|
||||
|
||||
## 四、前端处理清单
|
||||
|
||||
- [ ] 出行人卡片读取 `idProvinceName` 并按空值规则显示省份标签。
|
||||
- [ ] 按字符串 `travelerId/planId` 建立双向关联,不转换为 `Number`。
|
||||
- [ ] 同时处理 `transport.arrive`、`transport.depart` 和全部 `transport.batches[]`。
|
||||
- [ ] 按 `direction` 区分抵达/离开,并支持同方向多个批次。
|
||||
- [ ] 每个大交通段展示其 `travelerIds[]` 对应的出行人。
|
||||
- [ ] 不使用 `travelerNames`、脱敏姓名或数组位置作为关联依据。
|
||||
- [ ] 覆盖单批、多批、无关联人、无大交通、省份为空和 `direction=null` 场景。
|
||||
|
||||
## 五、验证证据
|
||||
|
||||
- 后端提交:`45b631cd0`;PR:[wx/HL#5212](https://git.1814.love:8443/wx/HL/pulls/5212)。
|
||||
- `mvn -pl hl-order-service-v3,hl-fleet-service -am verify`、fleet `spotless:check` 和定向测试全部通过。
|
||||
- 测试环境分支部署成功:order task `e1fc0581`、fleet task `d4cea89b`,四个实例健康。
|
||||
- 经网关遍历 11 条看板订单,详情成功 11/11;30 名出行人中 24 名返回可识别省份。
|
||||
- 9 个真实大交通段共验证 28 组双向 ID 关联,所有 ID 均为 JSON 字符串,未出现身份证明文字段。
|
||||
- 临时构造 3 个返程批次验证 `depart + batches[2]` 后已通过业务 API 完整清理,临时批次残留 0。
|
||||
- order、fleet、gateway 两实例自部署起均无目标 ERROR/Exception。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`;前端按“前端处理清单”接入即可。
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
frontend_status: "implemented"
|
||||
frontend_owner: "hl-ui-codex"
|
||||
frontend_ref: "mmg/hl-ui@06f9d4dce58ef64c3e5de96e44754c0793286d06"
|
||||
updated_at: "2026-07-24T07:15:17.169Z"
|
||||
---
|
||||
# 车务:派单通知预览补齐接送与行程数据
|
||||
|
||||
> **服务**: hl-fleet-service(8087/8187)
|
||||
> **PR**: #5213
|
||||
> **Issue**: #5211
|
||||
> **日期**: 2026-07-24
|
||||
> **影响范围**: 管理后台派车弹窗“排车待确认”通知预览
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化
|
||||
|
||||
原预览接口只读取订单主表基础字段,导致已有大交通和行程的订单仍把“接团、送团、行程链接、有效期”渲染为空。现在预览与 HOLD 实际发送共用接送口径,并在不创建派单、不写短链记录的前提下补齐行程长链和有效期。
|
||||
|
||||
## 一、变更接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 变更类型 |
|
||||
|------|------|------|----------|
|
||||
| 微信通知模板预览 | POST | `/admin/fleet/message-templates/{templateId}/render` | 响应内容修正,结构不变 |
|
||||
|
||||
### 入参
|
||||
|
||||
请求体字段、类型和必填规则均不变:
|
||||
|
||||
```json
|
||||
{
|
||||
"orderId": "订单雪花 ID",
|
||||
"vehicleId": "车辆雪花 ID",
|
||||
"driverId": "司机雪花 ID",
|
||||
"serviceDates": ["2026-07-29", "2026-07-30", "2026-07-31"],
|
||||
"chargeableServiceDates": ["2026-07-29", "2026-07-30", "2026-07-31"],
|
||||
"vehicleFeeWaiverReason": null
|
||||
}
|
||||
```
|
||||
|
||||
### 出参
|
||||
|
||||
`MessageTemplateRenderRespVO` 结构仍为:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `renderedBody` | String | 已替换变量的完整通知正文 |
|
||||
| `variablesUsed` | String[] | 模板实际引用的变量 key |
|
||||
|
||||
本次修正以下既有变量在 `renderedBody` 中的取值:
|
||||
|
||||
| 变量 | 新口径 |
|
||||
|------|--------|
|
||||
| `order.pickupInfo` | 从订单到达方向大交通格式化;无需平台接送或资料缺失时输出明确文案 |
|
||||
| `order.dropoffInfo` | 从订单返程方向大交通格式化;单方向缺失不再留空 |
|
||||
| `itinerary.url` | 使用 `orderId + 行程结束日` 无副作用现签订单级长链;签发失败时输出明确不可用文案 |
|
||||
| `itinerary.expireAt` | 与行程链接签发口径同源计算;无法计算时输出“待确认” |
|
||||
|
||||
## 二、前端调用约束
|
||||
|
||||
- 前端无需新增请求字段,也无需自行拼接接送或行程文案。
|
||||
- 继续直接展示后端返回的 `renderedBody`。
|
||||
- 预览中的行程链接不会提前创建派单、派单短链或通知记录;真实发送仍由 HOLD 冻结链路生成稳定短链。
|
||||
- 数据不可用时后端返回明确降级文案,前端不要再把这些文案转换为空串。
|
||||
|
||||
## 三、不影响范围
|
||||
|
||||
- 不修改模板、派单和通知接口的 JSON 结构。
|
||||
- 不修改订单、大交通或行程数据。
|
||||
- 不改变 HOLD 通知冻结、Outbox 投递和真实短链幂等规则。
|
||||
- 不涉及 `hl-ui` 代码修改。
|
||||
|
||||
## 四、验证
|
||||
|
||||
- 定向测试:`MessageTemplateRenderServiceTest` + `AssignmentHoldNotificationSnapshotFactoryTest`,22/22 通过。
|
||||
- 全量验证:`mvn -pl hl-fleet-service -am verify`,14 个 reactor 模块通过。
|
||||
- Spotless:598 files clean。
|
||||
- 测试环境网关验收将在 PR 合并并部署后补录到 Issue #5211。
|
||||
|
||||
## 五、相关文档
|
||||
|
||||
- [后端 Issue #5211](https://git.1814.love:8443/wx/HL/issues/5211)
|
||||
- [后端 PR #5213](https://git.1814.love:8443/wx/HL/pulls/5213)
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5215"
|
||||
title: "车务首页未完成状态独立汇总"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "implemented"
|
||||
frontend_status: "implemented"
|
||||
frontend_owner: "hl-ui-codex"
|
||||
frontend_ref: "mmg/hl-ui@a48846a3e35c06df3aef422e538d5cd198002c2c"
|
||||
updated_at: "2026-07-24T07:15:17.828Z"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-24T14:25:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】车务首页未完成状态独立汇总
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-fleet-service、hl-user-service
|
||||
>
|
||||
> **工单**: [wx/HL#5215](https://git.1814.love:8443/wx/HL/issues/5215)
|
||||
>
|
||||
> **影响范围**: 车务首页待处理汇总卡片
|
||||
|
||||
## 接口变更
|
||||
|
||||
`GET /admin/profile/dashboard?period=today` 在保留 `data.pendingArrangeVehicle`
|
||||
总数的基础上,新增固定顺序的 `data.pendingStatusCards`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"pendingArrangeVehicle": 5,
|
||||
"pendingStatusCards": [
|
||||
{
|
||||
"status": "unassigned",
|
||||
"statusLabel": "待派车",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"status": "holding",
|
||||
"statusLabel": "待确认",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段口径:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `pendingArrangeVehicle` | 近 7 天订单级唯一的全部未完成配车订单数 |
|
||||
| `pendingStatusCards[].status` | 稳定状态键,固定为 `unassigned`、`holding` |
|
||||
| `pendingStatusCards[].statusLabel` | 后端中文标签,分别为“待派车”“待确认” |
|
||||
| `pendingStatusCards[].count` | 对应基础状态的订单级唯一数量 |
|
||||
|
||||
`unassigned_urgent` 归入 `unassigned`,`holding_urgent` 归入 `holding`。
|
||||
`assigned`(配置完成)、`canceled`、`completed` 不进入状态卡。接口始终按
|
||||
`unassigned`、`holding` 顺序返回两项;即使数量为 0 也不省略。
|
||||
|
||||
守恒关系:
|
||||
|
||||
```text
|
||||
sum(pendingStatusCards[].count)
|
||||
== pendingArrangeVehicle
|
||||
== upcomingTrips.length
|
||||
```
|
||||
|
||||
## 前端展示
|
||||
|
||||
首页汇总区应展示三张独立卡片:
|
||||
|
||||
| 卡片 | 数据源 | 建议语义色 |
|
||||
| --- | --- | --- |
|
||||
| 待安排车辆 | `pendingArrangeVehicle` | danger / 红色 |
|
||||
| 待派车 | `pendingStatusCards[status=unassigned].count` | warning / 橙色 |
|
||||
| 待确认 | `pendingStatusCards[status=holding].count` | processing / 蓝色 |
|
||||
|
||||
实现要求:
|
||||
|
||||
- 使用 `pendingStatusCards` 循环渲染状态卡,并以 `status` 作为稳定 key 和颜色映射依据;
|
||||
- 保留现有“待安排车辆”总卡,不要用状态卡替换总数;
|
||||
- 不要从 `upcomingTrips` 或派车槽位在前端重新汇总;
|
||||
- 数量为 0 时仍展示对应状态卡,避免布局和状态口径随数据变化;
|
||||
- 标签优先展示后端 `statusLabel`,颜色不得按中文文案判断。
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] 首页汇总区展示“待安排车辆、待派车、待确认”三张卡片。
|
||||
- [ ] 待安排车辆使用红色、待派车使用橙色、待确认使用蓝色。
|
||||
- [ ] 状态卡以 `status` 为 key,直接使用后端 `count`,不在前端重新统计。
|
||||
- [ ] 覆盖 `5/4/1`、两个状态均为 0、单个状态为 0 的展示场景。
|
||||
- [ ] 保持现有即将用车订单表格与操作逻辑不变。
|
||||
|
||||
## 后端验证
|
||||
|
||||
- `FleetDashboardSummaryServiceTest` 覆盖 `unassigned=4`、`holding=1`、
|
||||
紧急派生态归并、订单去重、配置完成排除及零值场景。
|
||||
- `LogisticsDashboardServiceTest` 与 Feign fallback 测试覆盖完整透传、
|
||||
滚动部署兼容和固定两项零值降级。
|
||||
- `mvn -pl hl-user-service,hl-fleet-service -am verify` 已通过。
|
||||
- 分支 `fix/5215-fleet-dashboard-status-cards` 已按 fleet、user 顺序部署,
|
||||
四个服务实例均健康。
|
||||
- 2026-07-24 经测试网关验证:
|
||||
`pendingArrangeVehicle=5`、`unassigned=4`、`holding=1`、
|
||||
`upcomingTrips.length=5`。
|
||||
- fleet、user 与 gateway 部署完成窗口内 ERROR 级别日志均为 0,
|
||||
未发现 dashboard 目标异常。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5216"
|
||||
title: "派车看板补充槽位接送路线与就绪摘要"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "implemented"
|
||||
frontend_status: "implemented"
|
||||
frontend_owner: "hl-ui-codex"
|
||||
frontend_ref: "mmg/hl-ui@41f307090eccfdf3d06deabce8bc4f3d2be9a99a"
|
||||
updated_at: "2026-07-24T07:25:59.029Z"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-24T14:24:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】派车看板补充槽位接送路线与就绪摘要
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-order-service-v3、hl-fleet-service
|
||||
>
|
||||
> **工单**: [wx/HL#5216](https://git.1814.love:8443/wx/HL/issues/5216)
|
||||
>
|
||||
> **影响范围**: 车务管理 → 派车看板卡片
|
||||
|
||||
## 业务口径
|
||||
|
||||
派车看板卡片本身应足够车务完成日常派车判断,详情页只用于查看更深信息。每张卡对应一个稳定车辆槽位,
|
||||
同时显示当前需求全部槽位的派车进度、该槽位服务范围、接送批次、路线和资料就绪风险。
|
||||
|
||||
- 看板仍按车辆槽位维度返回,不改为订单维度。
|
||||
- 只统计订单当前有效用车需求,不混入已驳回、已失活或旧版本需求。
|
||||
- 行程或大交通缺失只作风险提示,`readiness.blocksAssignment` 固定为 `false`,不改变 `canAssign`。
|
||||
- 卡片接送摘要不返回出行人、接送备注或大交通自由文本备注。
|
||||
|
||||
## 变更接口
|
||||
|
||||
```http
|
||||
GET /admin/fleet/board/orders
|
||||
```
|
||||
|
||||
请求参数、筛选、排序、分页和 `records[]` 维度不变;每条 `records[]` 新增以下字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"assignmentSlotId": "2080200000000000001",
|
||||
"slotSummary": {
|
||||
"assignmentSlotId": "2080200000000000001",
|
||||
"slotIndex": 2,
|
||||
"totalSlots": 3,
|
||||
"serviceStartDate": "2026-07-29",
|
||||
"serviceEndDate": "2026-07-31",
|
||||
"serviceDays": 3,
|
||||
"requiredVehicleType": "mpv",
|
||||
"requiredVehicleTypeLabel": "商务车",
|
||||
"requiredSeats": 7
|
||||
},
|
||||
"assignmentProgress": {
|
||||
"totalSlots": 3,
|
||||
"unassignedSlots": 1,
|
||||
"holdingSlots": 1,
|
||||
"assignedSlots": 1,
|
||||
"completedSlots": 0,
|
||||
"canceledSlots": 0
|
||||
},
|
||||
"pickupSummary": {
|
||||
"required": true,
|
||||
"statusCode": "PARTIAL",
|
||||
"statusLabel": "接客信息部分缺失",
|
||||
"batchCount": 2,
|
||||
"readyBatchCount": 1,
|
||||
"transportNos": ["MU8345", "K7091"],
|
||||
"earliestTime": "2026-07-29T10:30:00",
|
||||
"latestTime": "2026-07-29T15:20:00",
|
||||
"stations": ["海拉尔机场"]
|
||||
},
|
||||
"dropoffSummary": {
|
||||
"required": true,
|
||||
"statusCode": "READY",
|
||||
"statusLabel": "送客信息已齐",
|
||||
"batchCount": 1,
|
||||
"readyBatchCount": 1,
|
||||
"transportNos": ["CA1234"],
|
||||
"earliestTime": "2026-07-31T18:00:00",
|
||||
"latestTime": "2026-07-31T18:00:00",
|
||||
"stations": ["海拉尔站"]
|
||||
},
|
||||
"routeSummary": "海拉尔区 → 额尔古纳市 → 满洲里市",
|
||||
"daysUntilDeparture": 5,
|
||||
"readiness": {
|
||||
"statusCode": "PARTIAL",
|
||||
"statusLabel": "部分信息待补",
|
||||
"itineraryStatusCode": "READY",
|
||||
"itineraryStatusLabel": "行程已完整",
|
||||
"itineraryDayCount": 3,
|
||||
"itineraryExpectedDayCount": 3,
|
||||
"missingItemCodes": ["PICKUP_TRANSFER"],
|
||||
"missingItemLabels": ["接客信息"],
|
||||
"blocksAssignment": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 当前槽位 `slotSummary`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `assignmentSlotId` | `String` | 稳定车辆槽位 ID,按雪花 ID 字符串处理 |
|
||||
| `slotIndex` | `Integer/null` | 当前槽位序号,**从 1 开始** |
|
||||
| `totalSlots` | `Integer` | 当前有效需求车辆槽位总数 |
|
||||
| `serviceStartDate/serviceEndDate` | `LocalDate/null` | 当前槽位实际服务范围 |
|
||||
| `serviceDays` | `Integer/null` | 服务范围闭区间天数 |
|
||||
| `requiredVehicleType` | `String/null` | 当前槽位车型规范编码 |
|
||||
| `requiredVehicleTypeLabel` | `String` | 当前槽位车型中文标签 |
|
||||
| `requiredSeats` | `Integer/null` | 当前槽位要求座位数 |
|
||||
|
||||
不要用既有整单 `requiredVehicles[]` 的数组位置猜当前卡片车型;当前卡片只读取 `slotSummary`。
|
||||
|
||||
### 整单进度 `assignmentProgress`
|
||||
|
||||
`assignmentProgress` 基于当前有效需求的全部稳定槽位计算,不受本次列表状态、车型、日期或关键词筛选影响。
|
||||
前端可直接展示“3 车:待派 1 / 排车中 1 / 已派 1”,不要用当前页 `records[]` 自行计数。
|
||||
|
||||
### 接送摘要 `pickupSummary/dropoffSummary`
|
||||
|
||||
| `statusCode` | 含义 |
|
||||
| --- | --- |
|
||||
| `READY` | 所有批次时间和站点均完整 |
|
||||
| `PARTIAL` | 至少一个批次完整,但仍有批次缺时间或站点 |
|
||||
| `MISSING` | 当前要求该方向接送,但没有完整批次 |
|
||||
| `NOT_REQUIRED` | 当前用车需求明确不要求该方向接送 |
|
||||
| `SOURCE_UNAVAILABLE` | order-v3 暂不可用,不能把它显示成“无需接送”或“资料已齐” |
|
||||
|
||||
`pickupAt/dropoffAt` 兼容字段继续保留;订单实时上下文可用时,优先回填对应方向第一个有效站点。
|
||||
|
||||
### 行程与就绪度
|
||||
|
||||
- `routeSummary` 按行程天顺序生成,并压缩连续重复地点;无地点时为 `null`。
|
||||
- `daysUntilDeparture` 是服务端当前日期到出团日的自然日数;负数表示已出团。
|
||||
- `readiness.statusCode` 为 `READY/PARTIAL/MISSING/SOURCE_UNAVAILABLE`。
|
||||
- `missingItemCodes` 当前可能包含:
|
||||
- `ORDER_CONTEXT`:订单实时信息不可用;
|
||||
- `ITINERARY_DAYS`:逐日行程缺失、天数不完整或日期仍是旧档期;
|
||||
- `ROUTE_SUMMARY`:行程天没有可用地点;
|
||||
- `PICKUP_TRANSFER`:要求接客但资料不完整;
|
||||
- `DROPOFF_TRANSFER`:要求送客但资料不完整。
|
||||
|
||||
页面使用后端 `statusLabel/missingItemLabels` 展示中文,不自行翻译状态码。
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] 卡片主信息区展示“第 `slotIndex/totalSlots` 车”、车型标签、座位数和槽位服务日期。
|
||||
- [ ] 展示 `assignmentProgress` 整单进度,不按当前页或筛选后记录重新计算。
|
||||
- [ ] 分别展示接客和送客摘要;多批次显示批次数、班次/车次、时间范围和站点。
|
||||
- [ ] `NOT_REQUIRED` 显示“无需接客/无需送客”,`SOURCE_UNAVAILABLE` 显示“信息暂不可用”。
|
||||
- [ ] 展示 `routeSummary`、`daysUntilDeparture` 和 `readiness` 风险提示。
|
||||
- [ ] 资料缺失时不得禁用派车按钮;操作能力继续只读 `canAssign/availableActionCodes`。
|
||||
- [ ] 不在卡片展示出行人、接送备注、大交通备注等敏感或自由文本信息。
|
||||
- [ ] `assignmentSlotId/assignmentId/assignmentGroupId/requirementId/orderId` 均按字符串处理。
|
||||
- [ ] 覆盖单车、多车、部分已派、多批次接送、无需接送、资料缺失和下游降级场景。
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- 不修改派车看板请求参数、分页、筛选、排序和操作接口。
|
||||
- 不修改 `canAssign/canRejectRequirement/availableActionCodes` 计算。
|
||||
- 不修改派单详情、矩阵派单、司机车辆占用、保险和费用。
|
||||
- 不迁移数据库,不写入订单、行程、大交通或派单数据。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 后端提交:`e73740c57caf295cac96d964e540279f581f1fb0`;PR:
|
||||
[wx/HL#5221](https://git.1814.love:8443/wx/HL/pulls/5221)。
|
||||
- `mvn -pl hl-fleet-service -am verify` 通过:2354 tests,0 failures/errors,skipped 1;
|
||||
Spotless 603 files clean。
|
||||
- `OrderFleetProviderServiceTest` 33 项、`BoardOrderServiceTest` 48 项及
|
||||
`AssignmentServiceTest` 稳定槽位聚合测试均通过。
|
||||
- `mvn -pl hl-order-service-v3 -am verify` 共执行 6643 tests,其中 6642 项通过;
|
||||
唯一错误是上游 `SettlementFinancialChecksMigrationTest` 在当前环境无法发现 Docker,
|
||||
与本次接口变更无关。Issue #5216 的对应验收项因此仍保持未勾选。
|
||||
- 功能分支部署任务:order-v3 `d0e26c90`、fleet `a4776dfa`,均成功完成双实例滚动部署。
|
||||
- 经测试网关实测 `GET /admin/fleet/board/orders?page=1&pageSize=20`:HTTP 200,返回 3 条真实记录;
|
||||
8 个新增字段在 3 条记录中全部存在且非空,观测到就绪状态 `PARTIAL`,接送状态
|
||||
`READY/MISSING`。
|
||||
- Nacos 实测:`hl-order-service-v3` 8086/8186、`hl-fleet-service` 8087/8187 均为 2/2 健康;
|
||||
四个新实例均有正常启动记录,启动后的运行日志未发现新增 ERROR、FATAL、Exception 或
|
||||
`Caused by`。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`;前端按“前端处理清单”接入即可。
|
||||
@@ -40,6 +40,7 @@ generated: "2026-07-22T10:46:00+08:00"
|
||||
| GET | `/admin/fleet/teams/:fleetTeamId` | 详情;负责人电话返回原值供编辑 |
|
||||
| POST | `/admin/fleet/teams` | 新增 |
|
||||
| PUT | `/admin/fleet/teams/:fleetTeamId` | 编辑 |
|
||||
| DELETE | `/admin/fleet/teams/:fleetTeamId` | 删除;仅名下无车辆且无未完结司机自助录入时允许 |
|
||||
| POST | `/admin/fleet/teams/:fleetTeamId/disable` | 停用;仍有在役车辆返回 `601103` |
|
||||
| POST | `/admin/fleet/teams/:fleetTeamId/enable` | 启用 |
|
||||
|
||||
@@ -104,13 +105,16 @@ generated: "2026-07-22T10:46:00+08:00"
|
||||
- 实际结算保存新增 `fleetTeamId`;旧 `fleet` 废弃。
|
||||
- 后端按车队类型派生 `OWN_COST/COOP_QUOTE`,不再把 `own` 当特殊业务编码。
|
||||
|
||||
历史字典迁入的车队可能没有负责人资料。新增、编辑请求中的 `leaderName`、`leaderPhone`、
|
||||
`settleType`、`sortOrder` 均为必填;前端编辑存量车队时必须提示车务人员补录真实资料,禁止用占位姓名或虚假电话自动填充。
|
||||
|
||||
## 独立菜单与权限
|
||||
|
||||
user-service 新增顶级菜单:
|
||||
user-service 在“车务管理”目录下新增子菜单:
|
||||
|
||||
- 路由:`/fleet/teams`
|
||||
- 组件:`fleet/teams/index`
|
||||
- 权限:`fleet:team:list`、`fleet:team:create`、`fleet:team:update`、`fleet:team:status`
|
||||
- 权限:`fleet:team:list`、`fleet:team:create`、`fleet:team:update`、`fleet:team:status`、`fleet:team:delete`
|
||||
- 默认角色:`SUPER_ADMIN`、`ADMIN`、`VEHICLE_MANAGER`
|
||||
|
||||
前端必须新增对应组件,否则菜单发布后会出现空路由。
|
||||
@@ -119,7 +123,11 @@ user-service 新增顶级菜单:
|
||||
|
||||
### 管理后台
|
||||
|
||||
1. 新增 `src/api/fleet/teams.js` 和 `src/views/fleet/teams/index.vue`,完成车队分页、新增、编辑、启停。
|
||||
1. 新增 `src/api/fleet/teams.js` 和 `src/views/fleet/teams/index.vue`,完成车队分页、新增、编辑、启停和删除:
|
||||
- “车队管理”必须显示在“车务管理”目录内,不得作为一级菜单处理。
|
||||
- 仅 `vehicleCount === 0` 时展示/启用删除动作;调用删除接口后刷新列表。
|
||||
- 后端仍会独立校验车辆及未完结司机录入关联,返回 `601107` 时提示“请先完成车辆/司机转移”。
|
||||
- 编辑历史迁入车队时补齐负责人、负责人电话、付款方式和排序。
|
||||
2. 车辆档案:
|
||||
- `src/views/fleet/vehicles/index.vue`
|
||||
- `src/views/fleet/vehicles/components/VehicleEditModal.vue`
|
||||
@@ -173,6 +181,7 @@ DELETE FROM sys_dict_type WHERE dict_type = 'fleet_attribution';
|
||||
- 车队已关联车辆后不能切换自有/合作类型,防止历史结算语义漂移。
|
||||
- 停用车队不出现在普通下拉;存量车辆编辑可回显当前停用车队,但不能切入其他停用车队。
|
||||
- 车队下仍有 `ACTIVE` 车辆时禁止停用,须先转移或停用车辆。
|
||||
- 车队只有在名下无车辆、无未完结司机自助录入时才能删除;正式司机通过常驻车辆归属,车辆未转移时删除同样会被拒绝(`601107`)。
|
||||
- 停用车队的存量车辆不得恢复在役,也不会进入派车候选或矩阵。
|
||||
- 对账保存车队名称、类型和付款方式快照,后续改主档不修改历史账期。
|
||||
- 负责人电话属于敏感信息,列表只展示脱敏值,不得写日志或进入前端埋点。
|
||||
@@ -180,6 +189,8 @@ DELETE FROM sys_dict_type WHERE dict_type = 'fleet_attribution';
|
||||
## 验收清单
|
||||
|
||||
- [ ] 独立车队菜单可分页、新增、编辑、启停,付款方式与资源页选项一致。
|
||||
- [ ] “车队管理”位于“车务管理”目录下;空车队可删除,非空车队删除入口禁用或明确提示后端 `601107`。
|
||||
- [ ] 历史迁入车队可通过编辑补齐负责人、负责人电话、付款方式和排序,保存时不允许提交空资料。
|
||||
- [ ] 车辆新增/编辑/筛选/详情/导入均使用动态车队,不再出现固定三项。
|
||||
- [ ] 司机 H5 新招、续签和管理端自带车审核均可选择动态车队并正确回显。
|
||||
- [ ] 派车候选、矩阵、甘特和对账能展示任意新增车队,颜色和分组稳定。
|
||||
|
||||
@@ -214,8 +214,13 @@ GET /admin/fleet/board/orders/{orderId}
|
||||
5. 禁止为了展示此页面调用明文接口 `POST /admin/fleet/board/orders/{orderId}/travelers/plain`。Step1 只使用详情响应中的脱敏 `travelers[]`。
|
||||
6. 空态明确:无节点显示“暂无行程节点”,无出行人显示“暂未填写出行人信息”;不得生成模拟节点或模拟出行人。
|
||||
7. 雪花 ID 禁止 `Number()` / `parseInt()`,统一按字符串处理。
|
||||
8. 用车备注与通用特殊诉求必须按字段来源分区展示,不能混在同一个“特殊要求”警示框:
|
||||
- `requirementRemark` 来源于定制师在“调整订单 → 车辆安排 → 备注/其他诉求”填写的自由文本,应显示在“定制师备注”或更准确的“用车备注”卡片中;例如“司机会蒙语”。
|
||||
- `specialTags[]` 来源于“通用特殊诉求”的多选标签,只在“特殊要求”区域展示标签;例如“儿童安全座椅”“大行李空间”“中文司机”。
|
||||
- `plannerNote` 是订单级定制师备注,与 `requirementRemark` 不是同一字段;两者同时存在时分行展示并标明来源,不得互相覆盖。
|
||||
- `requirements` 仅作为历史订单兼容文本;当 `requirementRemark` 或 `specialTags[]` 已有值时,不得把它们重复拼入 `requirements`。
|
||||
|
||||
推荐布局:顶部摘要下放横向“大交通”卡;左栏继续承载逐日节点时间线;右栏顺序为“出行人信息 → 客人留言 → 特殊要求 → 操作记录”。
|
||||
推荐布局:顶部摘要下放横向“大交通”卡;左栏继续承载逐日节点时间线;右栏顺序为“出行人信息 → 客人留言 → 定制师/用车备注 → 特殊要求 → 操作记录”。
|
||||
|
||||
---
|
||||
|
||||
@@ -242,6 +247,8 @@ GET /admin/fleet/board/orders/{orderId}
|
||||
- [ ] 每位出行人以“年龄 N 岁”的自然文案展示年龄,并可查看人员类型、性别、证件、国籍/民族、同住分组、关联大交通及资料状态。
|
||||
- [ ] 无节点/无出行人时展示真实空态,不生成模拟数据。
|
||||
- [ ] 页面 Network 只需现有详情请求,不调用出行人明文接口。
|
||||
- [ ] “备注/其他诉求”读取 `requirementRemark` 并显示在定制师/用车备注卡片;“特殊要求”只展示 `specialTags[]`,不会再把备注文本放入警示框。
|
||||
- [ ] `plannerNote` 与 `requirementRemark` 同时存在时分别展示且不覆盖,历史 `requirements` 不造成重复文案。
|
||||
- [ ] 现有留言、特殊要求、步骤条和操作记录不受影响。
|
||||
|
||||
---
|
||||
@@ -257,6 +264,7 @@ GET /admin/fleet/board/orders/{orderId}
|
||||
- 同一实测订单返回 2 个真实订单标签“自动化测试”“房务需求”,均包含颜色,`tagId` 均为字符串;响应不含 `bookingType`,前端无需也不得使用“企业包车”等硬编码兜底。
|
||||
- 同一实测订单已返回抵达大交通的班次、抵达时间和站点;该订单无返程段、无分批接送,接口按真实数据返回空值或空数组。
|
||||
- 该订单 14 个节点的 `startTime` 与 `timePeriod` 在订单行程源数据中均为空,接口如实返回 `null`;前端须展示“时间待定”,若要显示具体钟点需先补录订单行程节点时间。
|
||||
- 测试环境团号 `26-7042` 的调整单中,“备注/其他诉求”为“司机会蒙语”;详情接口已分别返回 `requirementRemark` 和 `specialTags[]`。截至 `v2.1@6e6a11bf`,`Step1OrderDetail.vue` 仍使用 `requirementRemark || requirements` 渲染“特殊要求”警示框,并仅用 `plannerNote` 渲染定制师留言,造成截图中的字段串位;按上方规则调整前端展示即可,后端无需新增字段。
|
||||
- 网关证据已由 `hl task` 登记,SHA-256:`8ff09cc804fb8d72fde6df3f338125b725c857d2c098b59f6b222f460da844a3`。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5146"
|
||||
title: "司机待确认通知与可选确认凭证"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-22T15:20:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】司机待确认通知与可选确认凭证
|
||||
|
||||
## 业务变化
|
||||
|
||||
派单待司机确认不再使用内部订单号和“模拟司机回复”。系统默认微信正文改为司机可直接判断是否接单的订车单,展示团号、服务日期、人数及人员类型构成、车辆、接送信息、客户备注和行程详情。
|
||||
|
||||
司机通过微信或电话真实回复后,由车务人员在页面登记“司机已确认接单”。确认凭证用于上传微信截图等佐证,改为选填,不上传也能登记司机确认并继续最终确认执行。
|
||||
|
||||
## 默认通知正文
|
||||
|
||||
```text
|
||||
呼伦旅行—订车单
|
||||
大含服务品质包,已包含接送机/站费用。不得与客人同餐;纯玩、无购物、无自费。请认真完成服务群内容。
|
||||
|
||||
师傅您好,请确认以下订车信息:
|
||||
团号:26-0503
|
||||
日期:2026-07-29 至 2026-07-31
|
||||
人数:4人(成人2、幼童1、婴儿1)
|
||||
车辆:蒙B-34567 丰田汉兰达(7座)
|
||||
接团:CA1234 2026-07-29 10:40 海拉尔东山国际机场
|
||||
送团:G529 2026-07-31 16:20 海拉尔站
|
||||
备注:草原沙漠精华6日
|
||||
行程详情:https://示例短链(有效期至 2026-07-31 23:59)
|
||||
请确认是否接单。
|
||||
```
|
||||
|
||||
- 不展示内部订单号。
|
||||
- 不展示客户姓名。
|
||||
- 人员构成仅展示数量大于 0 的类型,例如“成人2、儿童1、婴儿1”。
|
||||
- 运营已人工修改过的自定义模板不会被迁移覆盖。
|
||||
|
||||
## 变更接口
|
||||
|
||||
### 登记司机已确认
|
||||
|
||||
```http
|
||||
POST /admin/fleet/assignments/{assignmentId}/driver-confirmation
|
||||
```
|
||||
|
||||
请求示例(无凭证):
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "driver-confirm-20260722-001",
|
||||
"driverReplyNote": "司机微信回复已确认接单"
|
||||
}
|
||||
```
|
||||
|
||||
请求示例(有凭证):
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "driver-confirm-20260722-002",
|
||||
"driverReplyNote": "司机微信回复已确认接单",
|
||||
"evidenceFileIds": ["1934567890123456701"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `requestId` | 是 | 幂等标识,最长 64 字符;每次真实提交生成并在重试时保持不变 |
|
||||
| `driverReplyNote` | 否 | 司机回复原话或摘要,最长 512 字符 |
|
||||
| `evidenceFileIds` | 否 | 已上传文件的 fileId,最多 10 个;空数组、不传均允许 |
|
||||
|
||||
成功响应仍保持派单落库状态 `holding`,但返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"assignmentStatus": "holding",
|
||||
"stageCode": "driver_confirmed",
|
||||
"stageLabel": "司机已确认·待车务确认执行",
|
||||
"currentStep": 4,
|
||||
"assignmentGroupId": "1934567890123456790",
|
||||
"driverConfirmedAt": "2026-07-22T15:20:00",
|
||||
"evidenceFileIds": []
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### 最终确认执行
|
||||
|
||||
登记司机确认成功后,再调用:
|
||||
|
||||
```http
|
||||
POST /admin/fleet/assignments/{assignmentId}/confirm
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "fleet-final-confirm-20260722-001"
|
||||
}
|
||||
```
|
||||
|
||||
- 最终确认不再要求存在凭证。
|
||||
- 未调用 `driver-confirmation` 就直接最终确认,返回业务错误 `605025`,文案“请先登记司机已确认接单”。
|
||||
- `assignmentId`、`assignmentGroupId`、`evidenceFileIds[]` 均按字符串处理。
|
||||
|
||||
## 前端页面调整要求
|
||||
|
||||
目标区域:派单弹窗“待确认”步骤,当前实现位于 `src/views/fleet/board/components/Step3DriverConfirm.vue` 及其父级流程。
|
||||
|
||||
1. 删除 `useDispatchMessage.js` 中 `messageTemplates` 三条本地假模板和 `buildMessage()` 拼接正文,不再使用 `standard/sched/itin` 这类前端自造 ID。
|
||||
2. 弹窗打开时调用 `GET /admin/fleet/message-templates?templateType=hold_notify` 加载真实车管模板;默认选中后端返回的 `isDefault=true` 模板。
|
||||
3. 选择订单、车辆、司机或模板后,调用 `POST /admin/fleet/message-templates/{templateId}/render`,传 `orderId/vehicleId/driverId`,以响应 `renderedBody` 作为消息预览。
|
||||
4. 创建 HOLD 派单时传真实雪花 `messageTemplateId`。用户未编辑正文时不要传 `customBody`;用户确实改过本次正文时才传 `customBody`。
|
||||
5. 删除“对方正在输入…”和“模拟·师傅回复确认”,不得用本地布尔值伪造司机回复。
|
||||
6. 改为明确操作“登记司机已确认”,可同时填写可选的“司机回复摘要”。
|
||||
7. 增加“上传确认凭证(选填)”,复用现有文件上传能力;上传成功后只传 fileId,不传 URL。
|
||||
8. 点击登记时调用 `driver-confirmation`;只有响应成功且 `stageCode=driver_confirmed` 后,才允许进入最终确认执行。
|
||||
9. 没有上传凭证时不得禁用登记按钮,也不得阻止下一步;`evidenceFileIds` 可省略或传 `[]`。
|
||||
10. 最终确认调用 `confirm` 时只传新的 `requestId`,不要再传 `driverReplyNote`。
|
||||
11. 页面刷新后以详情返回的 `driverConfirmedAt`/阶段信息恢复状态,不使用前端临时模拟状态。
|
||||
|
||||
现有 API 文件 `src/api/fleet/message-template.js` 已封装模板列表和渲染接口,应直接复用。页面显示的模板名、正文和最终创建请求必须来自同一个后端模板,禁止再次在前端维护一套同名文案。
|
||||
|
||||
推荐文案:
|
||||
|
||||
- 操作按钮:“登记司机已确认”
|
||||
- 上传项:“确认凭证(选填)”
|
||||
- 等待态:“等待司机回复确认”
|
||||
- 已登记态:“司机已确认接单,待车务确认执行”
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] 模板列表来自后端 `hold_notify` 模板,默认选中 `isDefault=true`,不再使用前端假模板。
|
||||
- [ ] 消息预览调用后端 `render`,HOLD 创建传同一个真实 `messageTemplateId`,确保预览与发送一致。
|
||||
- [ ] 删除模拟司机回复入口及伪造回复气泡。
|
||||
- [ ] 增加真实“登记司机已确认”操作并调用 `driver-confirmation`。
|
||||
- [ ] 支持上传最多 10 个确认凭证,明确标记为选填。
|
||||
- [ ] 无凭证时仍可成功登记司机确认并执行最终确认。
|
||||
- [ ] `605025` 时提示“请先登记司机已确认接单”,不提示“缺少凭证”。
|
||||
- [ ] 页面刷新后能按后端状态恢复待回复/已确认阶段。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 后端:`hl-order-service-v3` clean verify、shared/Feign 生产者与消费者测试、fleet 定向测试、`spotless:check` 和 fleet `verify` 通过。
|
||||
- 部署:`hl-order-service-v3` 的 8086/8186、`hl-fleet-service` 的 8087/8187 两组测试实例均滚动发布成功并通过健康检查。
|
||||
- 网关:使用 `admin / VEHICLE_MANAGER` 经 `https://api.test.1814.love:9443` 验证模板列表、车务看板、车辆列表、司机列表和模板 render,HTTP 均为 200。
|
||||
- 默认 `hold_notify` 模板已按真实动态模板 ID 升级;固定服务规则、新变量、内部订单号/客户变量移除及渲染后无未解析变量均通过断言。
|
||||
- 扩大聚合测试排除了三个与本次无关的既有失败:`CrossSchemaMigrationAuditTest`、`InternalUserControllerTest2`、`MpOrderDetailJsonSampleTest`;本次受影响的 order-v3/fleet 验证未排除。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
@@ -0,0 +1,85 @@
|
||||
# 房务订单详情:订单概要补充团号
|
||||
|
||||
> **服务**: hl-order-service-v3
|
||||
> **PR**: [wx/HL#5151](https://git.1814.love:8443/wx/HL/pulls/5151)
|
||||
> **Issue**: [wx/HL#5150](https://git.1814.love:8443/wx/HL/issues/5150)
|
||||
> **日期**: 2026-07-22
|
||||
> **影响范围**: 管理后台 · 房务订单详情弹窗标题/订单概要
|
||||
|
||||
---
|
||||
|
||||
## 关键变化
|
||||
|
||||
房务订单详情响应的 `data.order` 新增 `teamNo`。前端可直接显示订单当前团号;尚未生成团号时字段为 `null`,不要以订单号或其他值拼造团号。
|
||||
|
||||
---
|
||||
|
||||
## 变更接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 变更类型 |
|
||||
|------|------|------|----------|
|
||||
| 房务订单详情 | GET | `/admin/house/orders/{orderId}` | 响应字段扩展 |
|
||||
|
||||
### 出参 `Result<HouseOrderDetailRespVO>`
|
||||
|
||||
| 字段路径 | 类型 | 是否新增 | 说明 |
|
||||
|----------|------|----------|------|
|
||||
| `data.order.teamNo` | `string/null` | 是 | 当前订单团号,取自 `order_main.team_no`;订金支付后生成,未生成时为 `null` |
|
||||
|
||||
有团号响应片段:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"order": {
|
||||
"orderId": "2044321098765432100",
|
||||
"orderNo": "HL20260721171011648",
|
||||
"teamNo": "26-0518",
|
||||
"productName": "孔知悦"
|
||||
}
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
尚未生成团号时:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"order": {
|
||||
"orderId": "2044321098765432100",
|
||||
"orderNo": "HL20260721171011648",
|
||||
"teamNo": null,
|
||||
"productName": "孔知悦"
|
||||
}
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端处理
|
||||
|
||||
1. 房务订单详情弹窗标题建议按“订单详情 · 订单号 · 团号 · 产品名”展示。
|
||||
2. 团号读取 `data.order.teamNo`;有值时显示,无值时隐藏团号片段或显示统一空值占位。
|
||||
3. 不要用 `orderNo` 回退为团号,也不要从列表缓存或历史快照读取团号。
|
||||
|
||||
---
|
||||
|
||||
## 边界与不影响范围
|
||||
|
||||
- 本次仅新增只读响应字段,不修改入参、状态机、房务权限和配房流程。
|
||||
- 现有响应字段保持兼容。
|
||||
- 无团号的存量订单正常返回 `teamNo: null`,无需数据迁移。
|
||||
- `D:/work2/hl-ui` 未修改,前端适配由管理后台项目单独处理。
|
||||
|
||||
---
|
||||
|
||||
## 后端验证
|
||||
|
||||
- `HouseDetailAggregatorTest` 覆盖有团号、未生成团号两种场景。
|
||||
- 定向测试结果:77 tests passed。
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5149"
|
||||
title: "派单详情补充团号与产品类型"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-22T16:22:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】派单详情补充团号与产品类型
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 仓库地址:<https://git.1814.love:8443/mmg/hl-ui.git>
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-order-service-v3、hl-fleet-service
|
||||
>
|
||||
> **后端 PR**: [wx/HL#5154](https://git.1814.love:8443/wx/HL/pulls/5154)
|
||||
>
|
||||
> **工单**: [wx/HL#5149](https://git.1814.love:8443/wx/HL/issues/5149)
|
||||
>
|
||||
> **影响范围**: 管理后台订单派车弹窗 Step1 订单详情
|
||||
|
||||
## 关键变化
|
||||
|
||||
`GET /admin/fleet/board/orders/{orderId}` 已有 `teamNo`,但前端当前把 `orderNo` 显示在标题和详情区,造成订单号被误认为团号。本次新增产品类型枚举值和中文名,前端必须改用 `teamNo` 展示团号。
|
||||
|
||||
## 变更接口
|
||||
|
||||
| 方法 | 路径 | 变更类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/fleet/board/orders/{orderId}` | 响应新增字段 | 新增 `productType/productTypeName`,继续返回 `teamNo` |
|
||||
|
||||
响应关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"orderNo": "HL20260721171011648",
|
||||
"teamNo": "26-0503",
|
||||
"customerName": "孔知悦",
|
||||
"productName": "测试核心产品-多档-固定比例",
|
||||
"productType": "CORE",
|
||||
"productTypeName": "核心产品",
|
||||
"relatedDetailReady": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `orderNo` | String/null | 订单号,仅保留业务查询和审计用途,不再作为弹窗团号展示 |
|
||||
| `teamNo` | String/null | 团号,弹窗标题与详情区的权威展示字段 |
|
||||
| `productType` | String/null | 产品类型枚举:`CORE/ROUTE/CUSTOM/GROUP` |
|
||||
| `productTypeName` | String/null | `product_type` 数据字典中文名,页面优先展示该字段 |
|
||||
|
||||
order 服务不可用、`relatedDetailReady=false` 时,新产品类型字段可能为 `null`,前端显示 `--`,不要从产品名称猜测类型。
|
||||
|
||||
## 前端展示口径
|
||||
|
||||
### 弹窗标题
|
||||
|
||||
当前:
|
||||
|
||||
```text
|
||||
派单 · {orderNo} · {customerName}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```text
|
||||
派单 · {teamNo || '--'} · {customerName}
|
||||
```
|
||||
|
||||
- 标题中不再展示 `orderNo`。
|
||||
- `teamNo` 为空时显示 `--`,不得回退为订单号,以免继续混淆两个业务编号。
|
||||
|
||||
### 订单详情区
|
||||
|
||||
- 在订单基础信息中明确增加 `团号:{teamNo || '--'}`。
|
||||
- 增加 `产品类型:{productTypeName || productType || '--'}`。
|
||||
- 产品名称继续读取 `productName`,与产品类型分开显示。
|
||||
- 图中顶部原 `HL202607...` 订单号位置改为团号;不要在同一区域重复显示订单号。
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] 弹窗标题将 `orderNo` 替换为 `teamNo`,空值显示 `--`。
|
||||
- [ ] 订单详情区新增或修正“团号”字段,读取 `teamNo`。
|
||||
- [ ] 订单详情区展示“产品类型”,优先读取 `productTypeName`,枚举值作为降级。
|
||||
- [ ] 产品名称与产品类型保持两个独立字段,不从名称推断类型。
|
||||
- [ ] 雪花 ID 继续按字符串处理。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- order→fleet 共享 DTO 生产者/消费者定向测试通过。
|
||||
- `mvn -pl hl-order-service-v3,hl-fleet-service -am test` 通过。
|
||||
- `mvn -pl hl-fleet-service spotless:check` 通过。
|
||||
- `mvn -pl hl-order-service-v3,hl-fleet-service -am verify` 通过。
|
||||
- `hl-order-service-v3` 8086/8186 与 `hl-fleet-service` 8087/8187 已从 `dev-v3` 滚动部署并保持健康。
|
||||
- 测试网关对截图订单实测 HTTP/业务码 200:`teamNo` 非空,`productType=CORE`,`productTypeName` 非空,且 `orderNo` 与 `teamNo` 为不同编号。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`。
|
||||
@@ -0,0 +1,153 @@
|
||||
# 【前端待处理·管理后台】#5156 车务团号、中文状态与常驻关系筛选
|
||||
|
||||
> **服务**: `hl-fleet-service`
|
||||
> **Issue**: [wx/HL#5156](https://git.1814.love:8443/wx/HL/issues/5156)
|
||||
> **日期**: 2026-07-22
|
||||
> **影响范围**: 管理后台车务派单看板、订单详情、车务矩阵、派单候选弹窗
|
||||
|
||||
---
|
||||
|
||||
## 关键结论
|
||||
|
||||
1. 看板与矩阵的可见订单主标识统一使用完整团号:
|
||||
|
||||
```js
|
||||
const visibleOrderCode = teamNo?.trim() || orderNo?.trim() || '—'
|
||||
```
|
||||
|
||||
该规则只替换可见文案,不得用 `teamNo` 替换 `id`、`orderNumericId`、`assignmentId` 或 `assignmentGroupId`,不得改变行键、路由和派单接口入参。
|
||||
|
||||
**2026-07-23 漏验收补充**:矩阵“已派订单占用条”的正文必须直接显示
|
||||
`visibleOrderCode`,不能只把团号放在鼠标悬浮 `title` 中。当前页面正文仍是
|
||||
“人员构成 · 路线”,车务无法在矩阵中直接识别团号。建议正文按
|
||||
“团号 · 人员构成 · 路线”排列;空间不足时优先保留完整团号,人员构成和路线可
|
||||
省略或截断,悬浮提示继续展示完整信息。
|
||||
|
||||
2. 订单详情 `activeAssignments[]` 新增 `assignmentStatusLabel`。页面只展示中文标签,例如 `assignmentStatus=assigned` 对应 `assignmentStatusLabel=已派车`;`assignmentStatus` 继续用于程序判断,不直接显示英文状态码。
|
||||
|
||||
3. 派单候选支持双向常驻关系展示和筛选:
|
||||
|
||||
- 车辆候选有常驻司机时,展示 `primaryDriverName` 和 `primaryDriverMaskedPhone`;`vehicleKeyword` 支持车牌、车型、常驻司机姓名或 11 位完整手机号。
|
||||
- 司机候选有常驻车辆时,展示 `residentVehiclePlate`;`driverKeyword` 支持司机姓名、11 位完整手机号或常驻车牌。
|
||||
- 完整手机号只作为精确筛选入参,响应仍只返回脱敏手机号。
|
||||
|
||||
## 接口契约
|
||||
|
||||
### 1. 看板、详情与矩阵团号
|
||||
|
||||
| 页面场景 | 接口 | 团号字段 |
|
||||
| --- | --- | --- |
|
||||
| 看板列表 | `GET /admin/fleet/board/orders` | `data.records[].teamNo` |
|
||||
| 看板详情 | `GET /admin/fleet/board/orders/{orderId}` | `data.teamNo` |
|
||||
| 矩阵已派占用 | `GET /admin/fleet/matrix/grid` | `data.vehicles[].assignments[].teamNo` |
|
||||
| 矩阵未派清单 | `GET /admin/fleet/matrix/unassigned-orders` | `data[].teamNo` |
|
||||
| 矩阵单日清单 | `GET /admin/fleet/matrix/day-orders` | `data[].teamNo` |
|
||||
|
||||
前端处理位置:
|
||||
|
||||
- `src/views/fleet/board/index.vue`
|
||||
- `src/views/fleet/board/components/OrderRowList.vue`
|
||||
- `src/views/fleet/board/components/OrderDrawer.vue`
|
||||
- `src/views/fleet/matrix/composables/useFleetMatrixData.js`
|
||||
- `src/views/fleet/_shared/gantt/components/OrderGantt.vue`
|
||||
- `src/views/fleet/_shared/gantt/components/OrderBar.vue`
|
||||
- `src/views/fleet/_shared/gantt/components/VehicleGantt.vue`
|
||||
- `src/views/fleet/_shared/gantt/components/UnassignedPool.vue`
|
||||
- `src/views/fleet/matrix/components/DayListModal.vue`
|
||||
|
||||
### 2. 订单详情中文状态
|
||||
|
||||
`GET /admin/fleet/board/orders/{orderId}` 的每个 `data.activeAssignments[]` 新增:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `assignmentStatus` | `string` | 稳定状态码,供逻辑判断 |
|
||||
| `assignmentStatusLabel` | `string` | 后端统一解析的中文状态标签,供页面展示 |
|
||||
|
||||
```json
|
||||
{
|
||||
"assignmentStatus": "assigned",
|
||||
"assignmentStatusLabel": "已派车"
|
||||
}
|
||||
```
|
||||
|
||||
`OrderDrawer.vue` 中有效派车组右侧标签改为 `assignment.assignmentStatusLabel || '—'`,不要再以 `assignmentStatus` 作为可见兜底。
|
||||
|
||||
### 3. 派单候选常驻关系
|
||||
|
||||
接口:`POST /admin/fleet/assignments/candidates`
|
||||
|
||||
请求关键词:
|
||||
|
||||
| 字段 | 新口径 |
|
||||
| --- | --- |
|
||||
| `vehicleKeyword` | 车牌/车型包含匹配;常驻司机姓名包含匹配;常驻司机 11 位完整手机号精确匹配 |
|
||||
| `driverKeyword` | 司机姓名包含匹配;司机 11 位完整手机号精确匹配;常驻车牌包含匹配 |
|
||||
|
||||
车辆候选 `data.vehicles.records[]`:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `primaryDriverId` | `string \| null` | 常驻司机 ID |
|
||||
| `primaryDriverName` | `string \| null` | 常驻司机姓名 |
|
||||
| `primaryDriverMaskedPhone` | `string \| null` | 常驻司机脱敏手机号 |
|
||||
|
||||
司机候选 `data.drivers.records[]` 继续返回:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `residentVehicleId` | `string \| null` | 常驻车辆 ID |
|
||||
| `residentVehiclePlate` | `string \| null` | 常驻车牌 |
|
||||
|
||||
前端处理要求:
|
||||
|
||||
- `VehiclePickerList.vue`:有常驻司机时显示“常驻司机:姓名 脱敏手机号”,无常驻时显示“无常驻”;搜索提示改为“搜索车牌/车型/常驻司机姓名/完整手机号”。
|
||||
- `DriverPickerList.vue`:保留现有“常驻 {residentVehiclePlate}”展示;搜索提示改为“搜索姓名/完整手机号/常驻车牌”。
|
||||
- `useVehicleDriverPicker.js`:关键词变化后分别重置对应页码为 1,并将原值传给 `vehicleKeyword` / `driverKeyword`;不在前端对当前页二次过滤。
|
||||
- 所有雪花 ID 保持字符串处理。
|
||||
|
||||
## 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"vehicles": {
|
||||
"records": [
|
||||
{
|
||||
"vehicleId": "2000000000000000101",
|
||||
"plate": "蒙A77777",
|
||||
"primaryDriverId": "2000000000000000201",
|
||||
"primaryDriverName": "张师傅",
|
||||
"primaryDriverMaskedPhone": "138****5678"
|
||||
}
|
||||
]
|
||||
},
|
||||
"drivers": {
|
||||
"records": [
|
||||
{
|
||||
"driverId": "2000000000000000201",
|
||||
"name": "张师傅",
|
||||
"maskedPhone": "138****5678",
|
||||
"residentVehicleId": "2000000000000000101",
|
||||
"residentVehiclePlate": "蒙A77777"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 验收清单
|
||||
|
||||
- [ ] 看板卡片、详情标题和矩阵各订单入口优先显示完整 `teamNo`,空值回退 `orderNo`。
|
||||
- [ ] 矩阵已派占用条正文直接显示 `teamNo || orderNo || '—'`;不能只在悬浮提示中显示。窄占用条优先保留完整团号,其次才是人员构成和路线。
|
||||
- [ ] 有效派车组状态只显示 `assignmentStatusLabel` 中文文案,不再出现 `assigned` 等英文状态码。
|
||||
- [ ] 有常驻司机的车辆显示姓名和脱敏手机号;可按姓名或完整手机号筛到对应车辆。
|
||||
- [ ] 有常驻车辆的司机显示常驻车牌;可按常驻车牌筛到对应司机。
|
||||
- [ ] 车牌/车型搜索和司机姓名/完整手机号搜索保持有效,关键词变化后分页正确重置。
|
||||
- [ ] 团号展示不改变详情、拖拽、派单、改派、取消等操作使用的稳定 ID。
|
||||
- [ ] 前端单元测试覆盖已派占用条正文团号、团号回退、中文状态、双向常驻关系显示与筛选参数。
|
||||
|
||||
## 后端核查证据
|
||||
|
||||
- `BoardOrderServiceTest` 覆盖 `assigned -> 已派车`。
|
||||
- `AssignmentCandidateServiceTest` 覆盖车辆按常驻司机姓名/完整手机号筛选、司机按常驻车牌筛选及双向关系回显。
|
||||
- `DriverServiceTest` 覆盖指定常驻司机集合内的姓名/完整手机号查询,明文手机号不离开司机域。
|
||||
@@ -0,0 +1,124 @@
|
||||
# 【前端待处理·管理后台】#5160 一名司机可绑定多辆常驻车
|
||||
|
||||
> **服务**: `hl-fleet-service`
|
||||
> **Issue**: [wx/HL#5160](https://git.1814.love:8443/wx/HL/issues/5160)
|
||||
> **PR**: [wx/HL#5164](https://git.1814.love:8443/wx/HL/pulls/5164)
|
||||
> **日期**: 2026-07-22
|
||||
> **影响范围**: 管理后台司机档案、车辆档案与车务派单候选
|
||||
|
||||
---
|
||||
|
||||
## 关键变化
|
||||
|
||||
- 常驻关系调整为“一辆车至多一名常驻司机,一名司机可常驻多辆车”。常驻关系仍以 `fleet_vehicle.primary_driver_id` 为权威,与实际派单关系相互独立。
|
||||
- 新增司机侧常驻车辆全集替换接口;空数组表示全部解绑。接口会先校验全部目标车辆,任一车辆已被其他司机占用时整单失败,不产生部分写入。
|
||||
- 旧单车接口和旧单车响应字段继续保留。旧接口等价于把全集替换为单元素或空集合;旧响应字段固定取按车辆 ID 升序后的第一辆。
|
||||
- `onlyResidentUnbound` 司机筛选参数兼容保留但不再生效,因为司机不再存在“已被一辆车占用”的状态。车辆侧“仅无常驻司机车辆”筛选仍有效。
|
||||
|
||||
## 接口清单
|
||||
|
||||
| # | 方法 | 路径 | 变更 |
|
||||
|---|---|---|---|
|
||||
| 1 | `PUT` | `/admin/fleet/drivers/{driverId}/resident-vehicles` | 新增:全量替换司机常驻车辆集合 |
|
||||
| 2 | `PUT` | `/admin/fleet/drivers/{driverId}/resident-vehicle` | 保留:旧单车契约,内部按全集替换执行 |
|
||||
| 3 | `GET` | `/admin/fleet/drivers/{driverId}` | 新增 `residentVehicles[]` |
|
||||
| 4 | `GET` | `/admin/fleet/drivers` | 列表项新增 `residentVehicles[]`;`onlyResidentUnbound` 废弃 |
|
||||
| 5 | `POST` | `/admin/fleet/assignments/candidates` | 司机候选与已选司机回显新增完整常驻车辆集合 |
|
||||
| 6 | 车辆新增/编辑/导入 | 既有车辆档案接口 | 同一司机已常驻其他车辆时不再返回 `605022` |
|
||||
|
||||
## 1. 全量替换常驻车辆
|
||||
|
||||
`PUT /admin/fleet/drivers/{driverId}/resident-vehicles`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"vehicleIds": [
|
||||
"2079857985374363650",
|
||||
"2079857985697308674",
|
||||
"2079857985848320002"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `vehicleIds` | `string[]` | ✅ | 最多 100 个 | 目标全集;`[]` 表示全部解绑;雪花 ID 必须按字符串处理 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
错误行为:
|
||||
|
||||
- 司机不存在:`600205`
|
||||
- 任一车辆不存在:`600110`
|
||||
- 任一目标车辆属于其他常驻司机:`605023`
|
||||
- 缺少 `vehicleIds` 或超过 100 个:`400`
|
||||
|
||||
## 2. 司机详情与列表
|
||||
|
||||
详情和分页列表项新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"residentVehiclePlate": "蒙A-T1557",
|
||||
"residentVehicles": [
|
||||
{
|
||||
"vehicleId": "2079857985374363650",
|
||||
"plate": "蒙A-T1557",
|
||||
"modelName": "丰田普拉多",
|
||||
"vehicleTypeId": "..."
|
||||
},
|
||||
{
|
||||
"vehicleId": "2079857985697308674",
|
||||
"plate": "蒙A-U1557",
|
||||
"modelName": "丰田汉兰达",
|
||||
"vehicleTypeId": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`residentVehicles` 恒按 `vehicleId` 升序;无常驻车辆时为 `[]`。兼容字段 `residentVehiclePlate` 取第一项车牌,无数据时为 `null`。
|
||||
|
||||
## 3. 派单候选
|
||||
|
||||
`POST /admin/fleet/assignments/candidates`:
|
||||
|
||||
- `data.drivers.records[].residentVehicles[]` 新增全部 `{ vehicleId, plate }`;旧 `residentVehicleId` / `residentVehiclePlate` 取第一项。
|
||||
- `data.selectedDriverResidentVehicles[]` 新增已选司机的全部车辆候选快照;旧 `selectedDriverResidentVehicle` 取第一项。
|
||||
- 所选车辆命中司机常驻集合中的任意一辆,均视为常驻匹配,不触发跨常驻确认。
|
||||
- `driverKeyword` 可命中该司机任意常驻车牌。
|
||||
|
||||
## 不影响范围
|
||||
|
||||
- 每辆车仍只有一个 `primaryDriverId`,车辆侧选择常驻司机仍是单选。
|
||||
- 实际订单派车不修改常驻关系;跨常驻车辆派单规则继续有效。
|
||||
- 旧接口、旧单车字段与 H5 单车续签契约继续可用。
|
||||
- 本次只提供后端契约,不直接修改 `hl-ui`。
|
||||
|
||||
## 验收清单
|
||||
|
||||
- [ ] 司机编辑可提交多辆车辆的 `vehicleIds` 全集,保存后详情回显相同集合。
|
||||
- [ ] 空数组可全部解绑;重复提交相同集合幂等成功。
|
||||
- [ ] 目标车辆被其他司机占用时整单失败,原绑定保持不变。
|
||||
- [ ] 车辆档案可把同一司机设为多辆车的常驻司机。
|
||||
- [ ] 派单选择司机的任一常驻车辆都显示常驻匹配,其他车辆仍按跨常驻规则提示。
|
||||
- [ ] 所有雪花 ID 保持字符串处理。
|
||||
|
||||
## 测试环境验证
|
||||
|
||||
2026-07-22 经测试网关 `https://api.test.1814.love:9443` 验证:
|
||||
|
||||
- `PUT /admin/fleet/drivers/2079857983403024385/resident-vehicles` 提交 3 个车辆 ID → `code=200`。
|
||||
- `GET /admin/fleet/drivers/2079857983403024385` → `residentVehicles` 按 ID 升序返回 3 项,兼容字段 `residentVehiclePlate=蒙A-T1557`。
|
||||
- 分别读取 3 辆车辆详情,`primaryDriverId` 均为 `2079857983403024385`,`primaryDriverName=朝鲁门`:
|
||||
- `蒙A-T1557` / 丰田普拉多
|
||||
- `蒙A-U1557` / 丰田汉兰达
|
||||
- `蒙A-V1557` / 丰田兰德酷路泽
|
||||
- 测试环境部署任务:`1485774a`,`hl-fleet-service` 从 `dev-v3` 部署成功。
|
||||
- 后端验证:定向 527 个测试通过;`spotless:check` 通过;最新 `dev-v3` 基线执行 `mvn -pl hl-fleet-service -am verify` 通过。
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5158"
|
||||
title: "派车按行程日标记车费日期"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-22T18:00:00+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】派车按行程日标记车费日期
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-fleet-service
|
||||
>
|
||||
> **后端 PR**: [wx/HL#5166](https://git.1814.love:8443/wx/HL/pulls/5166)、[wx/HL#5167](https://git.1814.love:8443/wx/HL/pulls/5167)
|
||||
>
|
||||
> **工单**: [wx/HL#5158](https://git.1814.love:8443/wx/HL/issues/5158)
|
||||
>
|
||||
> **影响范围**: 管理后台派单创建、修改派单、派单详情及待司机确认通知预览
|
||||
|
||||
## 关键变化
|
||||
|
||||
派单的“服务日”和“收取车费日”现在是两个不同概念。未勾选车费的日期仍然是正常派车服务日,继续占用司机和车辆并按原规则处理保险,只把当天车费记为 `0`;不要把未勾选日期当成取消派车。
|
||||
|
||||
历史派单及未传新字段的调用均按“全部服务日收取车费”处理,不改变旧数据金额。
|
||||
|
||||
## 变更接口
|
||||
|
||||
| 方法 | 路径 | 变更类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/admin/fleet/assignments` | 请求新增字段 | 创建排车/直接派车时冻结计费服务日 |
|
||||
| POST | `/admin/fleet/assignments/{assignmentId}/change` | 请求能力扩展 | 支持不换车、不换司机,仅修改已确认派单的计费日 |
|
||||
| GET | `/admin/fleet/board/orders/{orderId}` | 响应新增字段 | 当前有效派车组返回计费日、免费服务日及说明 |
|
||||
| POST | `/admin/fleet/message-templates/{templateId}/render` | 请求与模板变量新增 | 预览计费安排;默认待确认模板已增加车费安排 |
|
||||
|
||||
## 创建派单
|
||||
|
||||
`POST /admin/fleet/assignments` 的原字段不变,新增:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chargeableServiceDates` | `LocalDate[]/null` | 否 | 收取车费的服务日期;不传或 `null` 表示全部服务日,空数组表示全部免费 |
|
||||
| `vehicleFeeWaiverReason` | `String/null` | 条件必填 | 免费服务日说明,最长 256 字;全部免费时必填 |
|
||||
| `confirmAllServiceDatesFree` | `Boolean/null` | 条件必填 | `chargeableServiceDates=[]` 时必须显式传 `true` |
|
||||
|
||||
部分日期计费示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"orderId": "2046400000000000001",
|
||||
"startDate": "2026-07-29",
|
||||
"endDate": "2026-07-31",
|
||||
"vehicleId": "2046400000000000101",
|
||||
"driverId": "2046400000000000201",
|
||||
"holdMode": 1,
|
||||
"chargeableServiceDates": ["2026-07-30"],
|
||||
"vehicleFeeWaiverReason": "首尾接送已包含在团费中",
|
||||
"requestId": "dispatch-5158-example"
|
||||
}
|
||||
```
|
||||
|
||||
全部免费示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"chargeableServiceDates": [],
|
||||
"vehicleFeeWaiverReason": "本团车费由合作方统一结算",
|
||||
"confirmAllServiceDatesFree": true
|
||||
}
|
||||
```
|
||||
|
||||
校验规则:
|
||||
|
||||
- 所有日期必须属于本次派车组的服务日期,否则返回参数错误。
|
||||
- 空数组但未二次确认,返回“全部服务日免费时必须二次确认”。
|
||||
- 空数组但未填写说明,返回“全部服务日免费时必须填写原因”。
|
||||
- `null` 与不传保持兼容,默认所有服务日计费。
|
||||
|
||||
## 修改已确认派单的计费日
|
||||
|
||||
`POST /admin/fleet/assignments/{assignmentId}/change` 复用同名三个字段。仅调整车费时可以不传 `newVehicleId/newDriverId`,但必须:
|
||||
|
||||
- `holdMode=0`;
|
||||
- `effectiveDate` 指定修改生效日;
|
||||
- `reason` 填写本次修改原因;
|
||||
- `chargeableServiceDates` 表示从 `effectiveDate` 起目标切片中仍收车费的日期,不能包含生效日前日期。
|
||||
|
||||
```json
|
||||
{
|
||||
"effectiveDate": "2026-07-29",
|
||||
"holdMode": 0,
|
||||
"chargeableServiceDates": ["2026-07-30"],
|
||||
"vehicleFeeWaiverReason": "首尾接送已包含在团费中",
|
||||
"reason": "按实际结算范围调整",
|
||||
"requestId": "change-fee-5158-example"
|
||||
}
|
||||
```
|
||||
|
||||
已确认派单会原地更新计费标记并写操作审计,不取消/重建派单,不改变司机、车辆、占用和保险。对应月份已经关账时返回 `605600`,前端应提示先由有权限人员重开账期。
|
||||
|
||||
## 派单详情新增字段
|
||||
|
||||
`GET /admin/fleet/board/orders/{orderId}` 的 `data.currentAssignment` 与 `data.activeAssignments[]` 新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"chargeableServiceDates": ["2026-07-30"],
|
||||
"freeServiceDates": ["2026-07-29", "2026-07-31"],
|
||||
"vehicleFeeWaiverReason": "首尾接送已包含在团费中"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `chargeableServiceDates` | `LocalDate[]` | 收取车费的服务日,按日期升序 |
|
||||
| `freeServiceDates` | `LocalDate[]` | 仍提供车辆服务但车费为 0 的日期,按日期升序 |
|
||||
| `vehicleFeeWaiverReason` | `String/null` | 免费服务日说明 |
|
||||
|
||||
## 待确认通知与模板预览
|
||||
|
||||
`POST /admin/fleet/message-templates/{templateId}/render` 请求新增:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `serviceDates` | `LocalDate[]/null` | 本次派车的实际服务日期;不传时按订单起止日生成 |
|
||||
| `chargeableServiceDates` | `LocalDate[]/null` | 预览中的计费日期;不传表示全部计费 |
|
||||
| `vehicleFeeWaiverReason` | `String/null` | 免费服务日说明 |
|
||||
|
||||
新增模板变量:
|
||||
|
||||
| 变量 | 示例 |
|
||||
| --- | --- |
|
||||
| `{{assignment.vehicleFeeSummary}}` | `收取车费:7月30日;免费服务日:7月29日、7月31日` |
|
||||
| `{{assignment.chargeableServiceDates}}` | `7月30日` |
|
||||
| `{{assignment.freeServiceDates}}` | `7月29日、7月31日` |
|
||||
| `{{assignment.vehicleFeeWaiverReason}}` | `首尾接送已包含在团费中` |
|
||||
|
||||
全部免费时摘要固定为“本团服务日均不计车费”,全部计费时为“全部服务日收取车费”。派单待确认通知会读取落库后的逐日切片并冻结正文;运营修改模板后不会追改已生成的通知。
|
||||
|
||||
系统默认“排车待确认”模板已增加“车费安排”和“免车费说明”。运营自行修改过的默认模板不会被数据库迁移覆盖,如需显示新内容,应在车管模板页面自行加入上述变量。
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] 派单页用订单实际行程服务日期生成多选项,默认全选,并明确标题为“收取车费日期”。
|
||||
- [ ] 未选日期标为“免费服务日(仍派车、仍占用、仍按规则投保)”,不得触发取消派车逻辑。
|
||||
- [ ] 全部取消勾选时显示二次确认并要求填写免费原因,提交 `confirmAllServiceDatesFree=true`。
|
||||
- [ ] 修改已确认派单时调用既有 `/change` 接口,不直接复用创建接口;提交 `holdMode=0`、`reason` 和完整目标日期集合。
|
||||
- [ ] 派单详情读取 `chargeableServiceDates/freeServiceDates` 回显,不根据车费金额反推。
|
||||
- [ ] 待确认短信预览把同一组 `serviceDates/chargeableServiceDates/vehicleFeeWaiverReason` 传给模板渲染接口。
|
||||
- [ ] 雪花 ID 继续按字符串处理,日期继续使用 `yyyy-MM-dd`。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 创建派单默认全计费、部分计费及全部免费二次确认均有单元测试。
|
||||
- 免费服务日仅车费归零,保险成本字段与派车占用保持不变。
|
||||
- 已确认派单变更写可靠 Outbox,失败可重试;已关账期在变更落库前拦截。
|
||||
- 待司机确认通知从实际逐日派车切片生成并冻结计费摘要。
|
||||
- `FleetServiceApplicationTest` 以 H2 真 Flyway 执行 65 条迁移通过;`spotless:check` 与 fleet `verify` 通过。
|
||||
- 测试环境部署任务 `784326bb` 成功,`hl-fleet-service` 8087/8187 双实例健康。
|
||||
- 以 `admin` 车务身份经测试网关实测模板列表、部分计费预览、全部免费预览、看板列表与详情,HTTP/业务码均为 200。
|
||||
- 部分计费正文实际包含“收取车费:7月30日;免费服务日:7月29日、7月31日”;全部免费正文实际包含“本团服务日均不计车费”和免费原因。
|
||||
- 看板详情 `currentAssignment` 已实际返回 `chargeableServiceDates/freeServiceDates/vehicleFeeWaiverReason` 契约字段。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`;前端按“前端处理清单”接入即可。
|
||||
@@ -0,0 +1,113 @@
|
||||
# 房务配房价格模型收口为协议价与结算价
|
||||
|
||||
> **服务**: hl-order-service-v3
|
||||
> **Issue**: [wx/HL#5176](https://git.1814.love:8443/wx/HL/issues/5176)
|
||||
> **PR**: [wx/HL#5179](https://git.1814.love:8443/wx/HL/pulls/5179)
|
||||
> **日期**: 2026-07-23
|
||||
> **影响范围**: 管理后台 · 房务配房提交/修改/详情、订单行程住宿回配展示
|
||||
|
||||
---
|
||||
|
||||
## 关键变化
|
||||
|
||||
房务配房价格只保留两个权威字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `protoPrice` | 协议价快照,元/间·晚 |
|
||||
| `settlementPrice` | 结算价快照,元/间·晚 |
|
||||
|
||||
历史 `sellPrice` 已从房务请求、响应、持久化、快照和内部契约中删除。订单行程住宿回配对象同时删除为兼容历史展示而重复返回的 `unitPrice`、`plannedCost`、`protocolPrice`;前端不得继续提交、读取或回退这些字段。
|
||||
|
||||
本变更仅清理房务配房链路,不影响行程节点、结算票等其他业务域中的同名价格字段。
|
||||
|
||||
---
|
||||
|
||||
## 变更接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 变更 |
|
||||
|------|------|------|------|
|
||||
| 提交配房方案 | POST | `/v3/admin/order/hotel-requirements/{requirementId}/assignments` | 入参项删除 `sellPrice` |
|
||||
| 修改单条配房 | PUT | `/v3/admin/order/assignments/{assignmentId}` | 入参删除 `sellPrice` |
|
||||
| 房务订单详情 | GET | `/admin/house/orders/{orderId}` | `itinerary[].assignments[]` 删除 `sellPrice` |
|
||||
| 订单行程详情 | GET | `/v3/admin/order/{orderId}/itinerary` | `hotelGroup.assignments[]` 删除重复价格字段,改为双价 |
|
||||
|
||||
### 配房提交/修改入参
|
||||
|
||||
只提交:
|
||||
|
||||
```json
|
||||
{
|
||||
"protoPrice": 588.00,
|
||||
"settlementPrice": 688.00
|
||||
}
|
||||
```
|
||||
|
||||
不要再提交:
|
||||
|
||||
```json
|
||||
{
|
||||
"sellPrice": 688.00
|
||||
}
|
||||
```
|
||||
|
||||
### 房务详情配房项
|
||||
|
||||
`data.itinerary[].assignments[]` 当前价格字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"protoPrice": "588.00",
|
||||
"settlementPrice": "688.00"
|
||||
}
|
||||
```
|
||||
|
||||
已删除:`sellPrice`。
|
||||
|
||||
### 订单行程住宿回配项
|
||||
|
||||
`data.hotelGroup.assignments[]` 当前价格字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"protoPrice": "588.00",
|
||||
"settlementPrice": "688.00"
|
||||
}
|
||||
```
|
||||
|
||||
已删除:
|
||||
|
||||
- `sellPrice`
|
||||
- `unitPrice`
|
||||
- `plannedCost`
|
||||
- `protocolPrice`
|
||||
|
||||
前端价格列直接读取 `settlementPrice`;需要同时展示成本参考时读取 `protoPrice`。不要为兼容旧页面在本地重新合成 `unitPrice`、`plannedCost` 或 `sellPrice`。
|
||||
|
||||
---
|
||||
|
||||
## 金额口径
|
||||
|
||||
- 房务配房、房务详情和订单行程详情均直接返回落库快照,不按当前资源价格日历重算。
|
||||
- 终止退款住宿单价改为从房务只读契约读取 `settlementPrice`;对外退款明细字段名仍为 `dealPrice`。
|
||||
- 签单住宿成本仍读取 `protoPrice`。
|
||||
- 核单住宿计划/实际成本继续按 `settlementPrice × roomCount` 派生,对外核单字段不在本次删除范围。
|
||||
|
||||
---
|
||||
|
||||
## 前端处理
|
||||
|
||||
1. 删除所有房务配房请求中的 `sellPrice`。
|
||||
2. 房务详情和订单行程住宿价格统一展示 `settlementPrice`。
|
||||
3. 协议价展示读取 `protoPrice`。
|
||||
4. 删除对 `sellPrice`、`unitPrice`、`plannedCost`、`protocolPrice` 的兼容读取与回退逻辑。
|
||||
5. 若接口返回的 `protoPrice` 或 `settlementPrice` 为 `null`,按统一空值样式展示,不用另一字段伪造。
|
||||
|
||||
---
|
||||
|
||||
## 后端验证
|
||||
|
||||
- 房务提交、修改、详情、历史快照、内部聚合、订单详情、退款和核单定向测试通过。
|
||||
- 订单详情集成测试确认仅返回 `protoPrice`、`settlementPrice`,旧价格字段不存在。
|
||||
- Flyway 新增迁移,精确删除 `house_hotel_assignment.sell_price` 与 `house_requirement_assignment_snapshot.sell_price`;其他业务域同名列不受影响。
|
||||
- `D:/work2/hl-ui` 未修改,前端适配由管理后台项目单独处理。
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5178"
|
||||
title: "用车手动加急与派车看板状态颜色"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-23T10:00:00+08:00"
|
||||
---
|
||||
|
||||
# 【新增接口·前端待处理·管理后台】用车手动加急与派车看板状态颜色
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-order-service-v3、hl-fleet-service
|
||||
>
|
||||
> **后端 PR**: [wx/HL#5181](https://git.1814.love:8443/wx/HL/pulls/5181)
|
||||
>
|
||||
> **工单**: [wx/HL#5178](https://git.1814.love:8443/wx/HL/issues/5178)
|
||||
>
|
||||
> **影响范围**: 订单详情“行程安排”用车卡片、车务派车看板列表及派单详情
|
||||
|
||||
## 业务口径
|
||||
|
||||
- 用车需求的“加急/取消加急”与房务需求保持一致:只改变优先级和页面提示,不修改需求状态、派单状态、司机车辆占用、保险或费用。
|
||||
- 手动加急优先于临近出团、等待回复等自动紧急规则;取消后由后端恢复现有自动紧急度。
|
||||
- 前端不得根据日期自行推断是否加急,也不得乐观修改本地状态;操作成功后重新拉取订单详情和派车看板。
|
||||
- 已完成或已失效需求不能加急。按钮按后端允许状态 `PENDING`、`PROCESSING` 显示。
|
||||
|
||||
## 变更接口
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/v3/admin/order/vehicle-requirement/{requirementId}/urgent` | 本单定制师或超级管理员 | 手动加急;重复调用幂等 |
|
||||
| POST | `/v3/admin/order/vehicle-requirement/{requirementId}/urgent/cancel` | 本单定制师或超级管理员 | 取消手动加急;重复调用幂等 |
|
||||
|
||||
请求无 Body,成功统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": null,
|
||||
"message": "成功",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
需要明确处理的业务错误:
|
||||
|
||||
| code | 含义 | 建议提示 |
|
||||
| --- | --- | --- |
|
||||
| `582084` | 当前账号不是本单定制师且不是超级管理员 | 仅本单定制师可操作用车加急 |
|
||||
| `582087` | 需求已完成、已失效或状态已变化 | 当前用车需求状态已变化,请刷新后重试 |
|
||||
|
||||
## 订单行程接口新增字段
|
||||
|
||||
`GET /v3/admin/order/{id}/itinerary` 的 `data.vehicleGroup.requirement` 新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"requirementId": "2046400000000000001",
|
||||
"status": "PENDING",
|
||||
"manualUrgent": true
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `manualUrgent` | `Boolean` | `true` 显示“取消加急”,`false` 显示“加急” |
|
||||
|
||||
订单详情“行程安排 → 用车安排”卡片应复用房务卡片的交互:
|
||||
|
||||
- 当前有效需求存在且状态为 `PENDING/PROCESSING` 时,在“联系车务”旁显示“加急”或“取消加急”。
|
||||
- 点击后调用对应接口;成功后重拉订单详情,并让派车看板列表失效/刷新。
|
||||
- 提交中禁用按钮,防止连续点击;接口错误显示后端业务文案。
|
||||
|
||||
## 派车看板列表契约
|
||||
|
||||
`GET /admin/fleet/board/orders` 每条 `data.records[]` 新增/统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"assignmentStatus": "unassigned_urgent",
|
||||
"assignmentStatusLabel": "待派车",
|
||||
"manualUrgent": true,
|
||||
"urgentBadge": "手动加急"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `assignmentStatus` | `String` | 后端计算后的权威状态码 |
|
||||
| `assignmentStatusLabel` | `String` | 后端统一中文状态文案,页面直接展示 |
|
||||
| `manualUrgent` | `Boolean` | 是否由定制师手动加急 |
|
||||
| `urgentBadge` | `String/null` | 手动加急固定为“手动加急”;自动加急返回既有 T-N 文案 |
|
||||
|
||||
同一状态内,后端已把手动加急订单排在自动加急和普通订单之前,前端不需要再次排序。
|
||||
|
||||
## 派单详情新增字段
|
||||
|
||||
`GET /admin/fleet/board/orders/{orderId}` 新增:
|
||||
|
||||
- `data.manualUrgent`
|
||||
- `data.currentAssignment.manualUrgent`
|
||||
- `data.activeAssignments[].manualUrgent`
|
||||
|
||||
当前派单的 `assignmentStatus/assignmentStatusLabel/urgentBadge` 同样是后端计算后的有效状态,详情页不得自行按日期覆盖。
|
||||
|
||||
## 页面颜色映射
|
||||
|
||||
派车看板卡片参照房务卡片形成清晰的状态色,优先使用现有设计 Token;不要只给状态标签上色。
|
||||
|
||||
| 判定顺序 | 卡片语义 | 推荐底色 / 边框 | 标签 |
|
||||
| --- | --- | --- | --- |
|
||||
| `manualUrgent=true` | 定制师手动加急 | 淡红 `#FFF1F0` / 红 `#FF4D4F` | `urgentBadge` |
|
||||
| `assignmentStatus=unassigned_urgent/holding_urgent` | 系统自动加急 | 淡橙 `#FFF7E6` / 橙 `#FA8C16` | `urgentBadge` |
|
||||
| `assignmentStatus=unassigned` | 待派车 | 淡黄 `#FFFBE6` / 黄 `#FAAD14` | `assignmentStatusLabel` |
|
||||
| `assignmentStatus=holding` | 待司机/车务确认 | 淡蓝 `#E6F4FF` / 蓝 `#1677FF` | `assignmentStatusLabel` |
|
||||
| `assignmentStatus=assigned` | 已确认执行 | 淡绿 `#F6FFED` / 绿 `#52C41A` | `assignmentStatusLabel` |
|
||||
| 完成、取消等终态 | 已结束 | 灰 `#F5F5F5` / 灰 `#BFBFBF` | `assignmentStatusLabel` |
|
||||
|
||||
样式优先级必须是 `manualUrgent` > 自动紧急状态 > 普通状态。卡片左侧强调边、背景和状态标签应同步变化,效果与房务“已回配/待处理”卡片一致。
|
||||
|
||||
## 前端处理清单
|
||||
|
||||
- [ ] `VehicleArrangeCard.vue` 在“联系车务”旁增加“加急/取消加急”,交互和按钮状态复用 `RoomArrangeCard.vue`。
|
||||
- [ ] `ArrangementTab.vue` 和订单详情父组件透传 `urgent/cancel-urgent` 事件,新增用车加急 API 方法。
|
||||
- [ ] 操作成功后重新拉取订单详情和派车看板,不在前端直接翻转 `manualUrgent`。
|
||||
- [ ] 派车看板卡片按上表给整卡着色,优先读取 `manualUrgent`,状态文案读取 `assignmentStatusLabel`。
|
||||
- [ ] 手动加急与自动加急用不同颜色和标签,不显示英文状态码。
|
||||
- [ ] 派单详情读取顶层及当前/有效派单的 `manualUrgent`,不根据出团日期反推。
|
||||
- [ ] 雪花 ID 继续按字符串处理。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 用车加急/取消加急的权限、状态门控和幂等覆盖单元测试。
|
||||
- order-v3 订单详情与 order-v3 → fleet 共享契约均覆盖 `manualUrgent`。
|
||||
- fleet 有效状态、排序、列表和详情映射覆盖手动加急优先规则。
|
||||
- order-v3 与 fleet 全量 `verify` 通过,fleet 同时通过 Spotless 门禁。
|
||||
- 测试环境滚动部署成功:order-v3 任务 `126a59e4`,8086/8186 双实例健康;fleet 任务 `a21c7f2e`,8087/8187 双实例健康。
|
||||
- 以 `wx` 定制师经网关加急,行程接口实际返回 `manualUrgent=true`;以 `admin` 车务读取看板,实际返回 `unassigned_urgent`、`待派车`、`手动加急`,派单详情顶层也返回 `manualUrgent=true`。
|
||||
- 验收结束已由 `wx` 取消加急并复查,看板恢复 `manualUrgent=false`、`unassigned`、`urgentBadge=null`,未遗留测试状态。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`;前端按“前端处理清单”接入即可。
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
schema: "hl-changelog/v1"
|
||||
ticket: "5193"
|
||||
title: "用车需求增加独立接机送机选择"
|
||||
consumer: "admin"
|
||||
backend: "verified"
|
||||
gateway: "verified"
|
||||
frontend: "pending"
|
||||
base: "dev-v3"
|
||||
generated: "2026-07-23T17:39:06+08:00"
|
||||
---
|
||||
|
||||
# 【修改接口·前端待处理·管理后台】用车需求增加独立接机送机选择
|
||||
|
||||
## 目标前端
|
||||
|
||||
- 端类型:管理后台(Web)
|
||||
- 目标仓库:`mmg/hl-ui`
|
||||
- 目标分支:`v2.1`
|
||||
- 联调/验收环境:<http://192.168.100.160:9527>
|
||||
- 小程序:无需处理
|
||||
|
||||
> **服务**: hl-order-service-v3、hl-fleet-service
|
||||
>
|
||||
> **工单**: [wx/HL#5193](https://git.1814.love:8443/wx/HL/issues/5193)
|
||||
>
|
||||
> **影响范围**: 提交/调整用车需求、订单详情用车摘要、车务派单详情
|
||||
|
||||
## 业务口径
|
||||
|
||||
“是否需要平台接送”拆分为两个相互独立的业务选择:
|
||||
|
||||
- `pickupRequired`:是否需要平台接机/接站;
|
||||
- `dropoffRequired`:是否需要平台送机/送站。
|
||||
|
||||
提交用车需求时两个选项默认都选中,即默认都为 `true`。定制师可以分别取消,支持四种组合。接机与送机选择是用车需求本身的明确口径,不从航班、站点、接送时间或大交通批次推断。
|
||||
|
||||
## 一、提交/调整用车需求
|
||||
|
||||
### 1. 直接提交或修改
|
||||
|
||||
`PUT /v3/admin/order/{id}/vehicle-requirement`
|
||||
|
||||
请求体新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"fleet": [
|
||||
{
|
||||
"vehicleType": "SUV",
|
||||
"seats": 7,
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"pickupRequired": true,
|
||||
"dropoffRequired": true,
|
||||
"specialTags": ["中文司机"],
|
||||
"remark": "司机会蒙语"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 调整订单
|
||||
|
||||
`POST /v3/admin/order/{id}/adjustment/submit`
|
||||
|
||||
`updates.vehicleRequirement` 新增相同字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"updates": {
|
||||
"vehicleRequirement": {
|
||||
"fleet": [
|
||||
{
|
||||
"vehicleType": "SUV",
|
||||
"seats": 7,
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"pickupRequired": false,
|
||||
"dropoffRequired": true,
|
||||
"specialTags": [],
|
||||
"remark": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `pickupRequired` | `Boolean` | 前端应显式提交 | `true` 需要接机/接站,`false` 不需要 |
|
||||
| `dropoffRequired` | `Boolean` | 前端应显式提交 | `true` 需要送机/送站,`false` 不需要 |
|
||||
|
||||
兼容规则:
|
||||
|
||||
- 首次提交缺少字段时,后端按 `true` 保存;
|
||||
- 修改或调整既有需求时缺少字段,后端继承当前有效需求的原值;
|
||||
- 前端不要依赖兼容兜底,提交时始终显式发送两个字段。
|
||||
|
||||
## 二、前端表单
|
||||
|
||||
在“车辆安排/提交用车需求”表单中增加两个独立开关或复选框:
|
||||
|
||||
- 是否需要接机/接站;
|
||||
- 是否需要送机/送站。
|
||||
|
||||
交互要求:
|
||||
|
||||
- 新建用车需求时两个选项默认选中;
|
||||
- 编辑或调整时以接口回显值为准,不要每次强制重置为选中;
|
||||
- 两个选项均可独立取消,不做互斥或联动;
|
||||
- 不根据有没有大交通、航班时间或站点决定勾选状态。
|
||||
|
||||
## 三、回显接口
|
||||
|
||||
以下响应均新增 `pickupRequired` 和 `dropoffRequired`,用于无损回显:
|
||||
|
||||
- 用车需求提交/修改响应;
|
||||
- `GET /v3/admin/order/{id}/adjustment/snapshot` 的 `data.vehicleRequirement`;
|
||||
- `GET /v3/admin/order/{id}/itinerary` 的 `data.vehicleGroup.requirement`;
|
||||
- order-v3 内部用车需求契约。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"pickupRequired": false,
|
||||
"dropoffRequired": true
|
||||
}
|
||||
```
|
||||
|
||||
## 四、车务派单详情
|
||||
|
||||
`GET /admin/fleet/board/orders/{orderId}` 的 `data.transport` 新增/明确返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"pickupRequired": false,
|
||||
"dropoffRequired": true
|
||||
}
|
||||
```
|
||||
|
||||
这两个值来自订单当前有效用车需求,是车务详情页展示的权威值。前端不得再用抵达/返程班次、站点、接送时间或批次记录推断。
|
||||
|
||||
页面分别显示:
|
||||
|
||||
| 字段值 | 接机标签 | 送机标签 |
|
||||
| --- | --- | --- |
|
||||
| `true` | 需要平台接机 | 需要平台送机 |
|
||||
| `false` | 无需平台接机 | 无需平台送机 |
|
||||
|
||||
不要再合并显示单个“需要平台接送”标签。滚动部署期间若字段暂为 `null`,可以暂不显示对应标签;部署完成后现有历史需求会按默认值返回 `true`。
|
||||
|
||||
## 五、前端处理清单
|
||||
|
||||
- [ ] 提交用车需求表单增加“是否需要接机/接站”和“是否需要送机/送站”。
|
||||
- [ ] 新建时两个选项默认选中,提交时显式发送两个 Boolean。
|
||||
- [ ] 调整订单预填读取 `vehicleRequirement.pickupRequired/dropoffRequired`,不覆盖既有值。
|
||||
- [ ] 订单详情用车需求摘要按两个字段分别展示。
|
||||
- [ ] 车务派单详情按 `transport.pickupRequired/dropoffRequired` 分别展示接机、送机标签。
|
||||
- [ ] 不再展示单个“需要平台接送”,不根据大交通信息反推。
|
||||
- [ ] 覆盖需要/不需要的四种组合及旧需求默认双 `true`。
|
||||
|
||||
## 六、不影响范围
|
||||
|
||||
- 大交通录入中的批次级 `pickupRequired` 继续表示该批次自身是否接送,本次不修改其录入和计算逻辑。
|
||||
- 接送班次、站点、时间和备注字段结构不变。
|
||||
- 车型、座位数、数量、特殊诉求和备注提交结构不变。
|
||||
|
||||
## 七、验证证据
|
||||
|
||||
- 后端提交:`2112396b1`;PR:[wx/HL#5197](https://git.1814.love:8443/wx/HL/pulls/5197),已合并 `dev-v3`(合并提交 `f9e05fbe4`)。
|
||||
- 用车需求默认值、显式选择、版本继承、调整透传、订单详情回显及 order-v3 → fleet 契约均有单元测试覆盖。
|
||||
- order-v3 定向测试 311 项、fleet board 定向测试 49 项通过。
|
||||
- `mvn -pl hl-order-service-v3 -am verify`、`mvn -pl hl-fleet-service -am verify` 和 fleet `spotless:check` 全部通过。
|
||||
- 测试环境滚动部署成功:order-v3 任务 `af6ff925`,8086/8186 两实例健康;fleet 任务 `e11bca29`,8087/8187 两实例健康。
|
||||
- 以 `admin` 车务经网关查询团号 `26-8550` 的派车详情,HTTP/code 200,`transport.pickupRequired=true`、`transport.dropoffRequired=true`。
|
||||
- 以 `wx` 定制师经网关查询同订单调整预填,HTTP/code 200,`vehicleRequirement.pickupRequired=true`、`vehicleRequirement.dropoffRequired=true`。
|
||||
- 测试库只读核验:两列均为 `NOT NULL DEFAULT 1`,该历史活动需求已迁移为 `1/1`。
|
||||
|
||||
> 本文是前端接入通知,不代表已修改或发布 `mmg/hl-ui`;前端按“前端处理清单”接入即可。
|
||||
@@ -0,0 +1,130 @@
|
||||
# 调整订单行程节点时间回显与修改(修改接口)
|
||||
|
||||
> 日期:2026-07-24
|
||||
> 工单:#5202
|
||||
> 服务:`hl-order-service-v3`
|
||||
> 前端状态:待处理(`D:/work2/hl-ui` 本次未修改)
|
||||
|
||||
## 结论
|
||||
|
||||
产品行程节点的两类时间在下单时均已固化到订单行程节点:
|
||||
|
||||
- `startTime`:精确开始时间,格式 `HH:mm`。
|
||||
- `timePeriod`:时间说明,值来自 `itinerary_time_period` 字典,例如 `MORNING`。
|
||||
|
||||
本次补齐“调整订单 → 行程”中的完整读写契约:
|
||||
|
||||
- 快照逐节点返回 `startTime`、`timePeriod`。
|
||||
- 调整提交可只修改时间,不要求同时改价或改数量。
|
||||
- 新增节点也可携带两类时间。
|
||||
- 两字段可分别存在,不强制互斥。
|
||||
- 旧订单或未设置时间的节点返回 `null`。
|
||||
|
||||
## 涉及接口
|
||||
|
||||
| 方法 | 路径 | 变化 |
|
||||
|---|---|---|
|
||||
| GET | `/v3/admin/order/{orderId}/adjustment/snapshot` | `data.itinerary.days[].nodes[]` 补齐 `timePeriod`,保留已有 `startTime` |
|
||||
| POST | `/v3/admin/order/{orderId}/adjustment/submit` | `updates.itinerary.days[].nodes[]` 支持提交 `startTime`、`timePeriod` |
|
||||
|
||||
## 快照出参
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"itinerary": {
|
||||
"days": [
|
||||
{
|
||||
"id": "8001",
|
||||
"dayNumber": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "9001",
|
||||
"title": "呼和诺尔草原旅游区",
|
||||
"startTime": "05:05",
|
||||
"timePeriod": null
|
||||
},
|
||||
{
|
||||
"id": "9002",
|
||||
"title": "额尔古纳湿地漂流",
|
||||
"startTime": null,
|
||||
"timePeriod": "EARLY_MORNING"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`timePeriod` 返回字典值,展示文字请使用现有 `itinerary_time_period` 字典翻译,不要在页面硬编码中文。
|
||||
|
||||
## 提交语义
|
||||
|
||||
节点时间沿用 patch 语义:
|
||||
|
||||
| 入参状态 | 含义 |
|
||||
|---|---|
|
||||
| 字段省略或传 `null` | 不修改该字段 |
|
||||
| `startTime: "09:05"` | 设置精确开始时间 |
|
||||
| `timePeriod: "MORNING"` | 设置时间说明 |
|
||||
| `startTime: ""` | 清空精确开始时间,后端落库为 `NULL` |
|
||||
| `timePeriod: ""` | 清空时间说明,后端落库为 `NULL` |
|
||||
|
||||
仅修改时间时的请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"updates": {
|
||||
"itinerary": {
|
||||
"days": [
|
||||
{
|
||||
"id": "8001",
|
||||
"dayNumber": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "9001",
|
||||
"startTime": "06:30"
|
||||
},
|
||||
{
|
||||
"id": "9002",
|
||||
"timePeriod": "AFTERNOON"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
清空示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "9001",
|
||||
"startTime": "",
|
||||
"timePeriod": ""
|
||||
}
|
||||
```
|
||||
|
||||
非空 `startTime` 必须是 24 小时制 `HH:mm`,例如 `09:05`;`9:05`、`24:00` 均不合法。
|
||||
|
||||
## 管理后台处理清单
|
||||
|
||||
当前 `FunItemAdjustModal.vue` 已从快照读取 `startTime`,但尚未渲染、参与 diff 或写入提交体;`timePeriod` 还未映射。需补:
|
||||
|
||||
- 节点草稿与基线同时保存 `startTime`、`timePeriod`。
|
||||
- 节点行增加精确时间选择器和 `itinerary_time_period` 字典下拉,并回显现有值。
|
||||
- `funItemsChanged` 与节点 diff 同时比较两字段;只有时间变化也要生成节点 patch。
|
||||
- `buildItineraryDays()` 对变化字段按需提交,未变化字段省略。
|
||||
- 用户清空后,提交体将该字段从前端 `null` 转为 `""`;不要直接传 `null`,否则后端按“不修改”处理。
|
||||
- 保留两字段可同时设置的能力,不在前端强制互斥。
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 新增出参字段兼容旧调用方。
|
||||
- `startTime` 原字段保持不变。
|
||||
- 未设置时间的历史数据返回 `null`,前端按空态展示。
|
||||
- 本次不改订单金额、节点价格、数量、顺序和资源绑定逻辑。
|
||||
@@ -0,0 +1,306 @@
|
||||
# ✨ 酒店与房型轻量下拉接口(#5183)
|
||||
|
||||
> **PR**: [#5184](https://git.1814.love:8443/wx/HL/pulls/5184)
|
||||
> **服务**: hl-resource-service
|
||||
> **更新时间**: 2026-07-23 14:05
|
||||
|
||||
## 1. 接口背景
|
||||
|
||||
管理后台在核单 Step1 手动新增住宿项时,需要异步搜索可用酒店,并在选定酒店后加载该酒店当前可用的房型。新增两个轻量接口,避免下拉框拉取完整资源详情或在前端过滤禁用数据。
|
||||
|
||||
## 2. 变更清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | 酒店轻量下拉分页 | GET | `/admin/hotel/options` | 新增接口 | 按酒店名称异步分页搜索,仅返回启用酒店 |
|
||||
| 2 | 启用房型轻量下拉 | GET | `/admin/hotel/{hotelId}/room-type-options` | 新增接口 | 返回指定酒店下的启用房型 |
|
||||
|
||||
## 3. 接口详情
|
||||
|
||||
### 3.1 酒店轻量下拉分页
|
||||
|
||||
- **使用场景**:酒店下拉框首次打开或用户输入关键词时调用。
|
||||
- **认证**:需要管理后台 JWT,使用 `Authorization: Bearer <token>`。
|
||||
- **幂等性**:幂等,只读查询。
|
||||
- **响应类型**:`Result<PageResult<HotelSimpleRespVO>>`。
|
||||
|
||||
#### Query 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 校验规则 | 说明 |
|
||||
|------|------|------|--------|----------|------|
|
||||
| `keyword` | String | 否 | `null` | 最长 100 个字符 | 酒店名称模糊匹配;空字符串或仅空白字符等同未传 |
|
||||
| `page` | Integer | 否 | `1` | 最小值 1 | 当前页码 |
|
||||
| `pageSize` | Integer | 否 | `20` | 1~100 | 每页条数 |
|
||||
|
||||
无请求体。
|
||||
|
||||
#### 完整出参
|
||||
|
||||
| 字段 | 类型 | 可空 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `code` | Integer | 否 | 业务状态码;成功为 `200` |
|
||||
| `message` | String | 否 | 响应消息;成功为 `成功` |
|
||||
| `success` | Boolean | 否 | `code == 200` 时为 `true` |
|
||||
| `traceId` | String | 是 | 链路追踪 ID |
|
||||
| `data` | Object | 否 | 分页数据 |
|
||||
| `data.records` | Array | 否 | 本页酒店列表;无匹配项时为空数组 |
|
||||
| `data.records[].hotelId` | String | 否 | 酒店 ID;必须按字符串保存和传递 |
|
||||
| `data.records[].hotelName` | String | 否 | 酒店名称 |
|
||||
| `data.total` | Integer | 否 | 符合条件的总记录数 |
|
||||
| `data.page` | Integer | 否 | 当前页码 |
|
||||
| `data.pageSize` | Integer | 否 | 每页条数 |
|
||||
|
||||
#### 错误码
|
||||
|
||||
| code | 含义 | 触发场景 |
|
||||
|------|------|----------|
|
||||
| `200` | 成功 | 查询完成,包括没有匹配数据 |
|
||||
| `400` | 参数校验失败 | `page < 1`、`pageSize` 不在 1~100,或 `keyword` 超过 100 个字符 |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 只返回启用状态的酒店,禁用酒店不会出现在任何分页结果中。
|
||||
- `keyword` 仅对酒店名称做模糊匹配。
|
||||
- 未传 `keyword` 时分页查询全部启用酒店。
|
||||
- 页码超过最后一页时,`records` 返回空数组,`total` 仍是符合条件的总记录数。
|
||||
- 结果按酒店名称、酒店 ID 升序排列。
|
||||
|
||||
#### 典型成功示例
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /admin/hotel/options?keyword=伯爵&page=1&pageSize=20
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"records": [
|
||||
{
|
||||
"hotelId": "9007199254740993",
|
||||
"hotelName": "伯爵酒店"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"pageSize": 20
|
||||
},
|
||||
"traceId": "a1b2c3d4-e5f6-7890",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 边界示例:无匹配数据
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /admin/hotel/options?keyword=不存在的酒店&page=1&pageSize=20
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"records": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"pageSize": 20
|
||||
},
|
||||
"traceId": "b2c3d4e5-f6a7-8901",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 异常示例:每页条数越界
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /admin/hotel/options?page=1&pageSize=101
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "每页条数最大为100",
|
||||
"data": null,
|
||||
"traceId": "c3d4e5f6-a7b8-9012",
|
||||
"success": false
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 启用房型轻量下拉
|
||||
|
||||
- **使用场景**:用户从酒店下拉框选定酒店后,加载该酒店可选择的房型。
|
||||
- **认证**:需要管理后台 JWT,使用 `Authorization: Bearer <token>`。
|
||||
- **幂等性**:幂等,只读查询。
|
||||
- **响应类型**:`Result<List<RoomTypeOptionRespVO>>`。
|
||||
|
||||
#### Path 入参
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `hotelId` | String | 是 | 已选酒店 ID;URL 中按十进制数字字符串传递 |
|
||||
|
||||
无 Query 参数,无请求体。
|
||||
|
||||
#### 完整出参
|
||||
|
||||
| 字段 | 类型 | 可空 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `code` | Integer | 否 | 业务状态码;成功为 `200` |
|
||||
| `message` | String | 否 | 响应消息;成功为 `成功` |
|
||||
| `success` | Boolean | 否 | `code == 200` 时为 `true` |
|
||||
| `traceId` | String | 是 | 链路追踪 ID |
|
||||
| `data` | Array | 否 | 启用房型列表;没有启用房型时为空数组 |
|
||||
| `data[].roomTypeId` | String | 否 | 房型 ID;必须按字符串保存和传递 |
|
||||
| `data[].roomTypeName` | String | 否 | 房型名称 |
|
||||
| `data[].roomCategory` | String | 否 | 房型分类,取值见第 4 节 |
|
||||
|
||||
#### 错误码
|
||||
|
||||
| code | 含义 | 触发场景 |
|
||||
|------|------|----------|
|
||||
| `200` | 成功 | 查询完成,包括酒店不存在或没有启用房型 |
|
||||
| `400` | 参数类型错误 | `hotelId` 不是可解析的十进制数字字符串 |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 只返回 `hotelId` 对应酒店下的启用房型,禁用房型不会返回。
|
||||
- 酒店不存在、酒店下没有房型或没有启用房型时,均返回成功和空数组。
|
||||
- 结果按房型排序值、房型 ID 升序排列。
|
||||
- 该接口不分页,应在酒店选择变化后重新请求,不能继续使用上一个酒店的房型结果。
|
||||
|
||||
#### 典型成功示例
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /admin/hotel/9007199254740993/room-type-options
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": [
|
||||
{
|
||||
"roomTypeId": "9007199254741993",
|
||||
"roomTypeName": "标准间",
|
||||
"roomCategory": "STANDARD"
|
||||
},
|
||||
{
|
||||
"roomTypeId": "9007199254741994",
|
||||
"roomTypeName": "家庭房",
|
||||
"roomCategory": "FAMILY"
|
||||
}
|
||||
],
|
||||
"traceId": "d4e5f6a7-b8c9-0123",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 边界示例:没有启用房型
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /admin/hotel/9007199254740999/room-type-options
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": [],
|
||||
"traceId": "e5f6a7b8-c9d0-1234",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 异常示例:酒店 ID 类型错误
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /admin/hotel/not-a-number/room-type-options
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "参数类型错误: hotelId='not-a-number'(需要 Long 类型)",
|
||||
"data": null,
|
||||
"traceId": "f6a7b8c9-d0e1-2345",
|
||||
"success": false
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 枚举 / 数据字典
|
||||
|
||||
### 4.1 `roomCategory`(字典类型 `room_category`)
|
||||
|
||||
**所属字段**:`RoomTypeOptionRespVO.roomCategory`
|
||||
**类型**:String
|
||||
**必填**:是
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `STANDARD` | 标间 | 标准房型 |
|
||||
| `SINGLE` | 单人间 | 单人房型 |
|
||||
| `TWIN` | 双床房 | 双床房型 |
|
||||
| `QUEEN` | 大床房 | 大床房型 |
|
||||
| `KING` | 豪华大床 | 豪华大床房型 |
|
||||
| `SUITE` | 套房 | 套房房型 |
|
||||
| `FAMILY` | 家庭房 | 家庭房型 |
|
||||
| `YURT` | 蒙古包 | 蒙古包房型 |
|
||||
| `SPECIAL` | 特色房 | 特色房型 |
|
||||
| `PARENT_CHILD` | 亲子房 | 亲子房型 |
|
||||
|
||||
## 5. 影响评估
|
||||
|
||||
- 两个接口均为新增,只读接口,不破坏现有接口兼容性。
|
||||
- 前端可按需接入;不要求与后端同步上线。
|
||||
- 所有酒店 ID、房型 ID 都按 `String` 处理,不要转换为 JavaScript `Number`。
|
||||
|
||||
## 6. 关联
|
||||
|
||||
- **Issue**: [#5183](https://git.1814.love:8443/wx/HL/issues/5183)
|
||||
- **PR**: [#5184](https://git.1814.love:8443/wx/HL/pulls/5184)
|
||||
- **Merge commit**: [0163a7a03](https://git.1814.love:8443/wx/HL/commit/0163a7a0308344691f2379e154c40069fd8d34ba)
|
||||
- **后端负责人**: @yaosutu
|
||||
@@ -0,0 +1,219 @@
|
||||
# 【🔧 修改接口·管理后台】Step2 票种规格字典(#5185)
|
||||
|
||||
> **PR**: #5191 | **更新时间**: 2026-07-23
|
||||
|
||||
## 1. 接口背景
|
||||
|
||||
Step2 门票/游玩项目需要统一的票种/规格选项。管理后台现在可以按字典类型加载启用选项,首个可用选项为“成人票”,避免前端写死选项文案。
|
||||
|
||||
## 2. 变更清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | 按类型查询字典数据 | GET | `/admin/dict/data/settlement_ticket_spec` | 修改接口 | 新增 `settlement_ticket_spec` 字典类型及启用项“成人票” |
|
||||
|
||||
## 3. 接口详情
|
||||
|
||||
### 3.1 按类型查询 Step2 票种规格
|
||||
|
||||
- **使用场景**:加载 Step2 门票/游玩项目的票种/规格下拉选项。
|
||||
- **认证**:需要管理后台 JWT。
|
||||
- **幂等性**:是,只读查询。
|
||||
- **限流**:无接口专属限流约定。
|
||||
- **排序**:按 `sortOrder` 升序,同一排序号再按 `dictDataId` 升序。
|
||||
- **过滤**:仅返回 `status=ACTIVE` 的字典项。
|
||||
|
||||
## 4. 接口入参
|
||||
|
||||
### 4.1 路径参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 固定值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `dictType` | String | 是 | `settlement_ticket_spec` | Step2 票种/规格字典类型编码 |
|
||||
|
||||
### 4.2 Query 参数与请求体
|
||||
|
||||
无 Query 参数,无请求体。
|
||||
|
||||
## 5. 出参(响应)
|
||||
|
||||
### 5.1 统一响应字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `code` | Integer | 业务状态码,成功为 `200` |
|
||||
| `message` | String | 响应消息,成功为“成功” |
|
||||
| `data` | Array | 启用的字典项列表;无匹配项时为 `[]` |
|
||||
| `traceId` | String / null | 链路追踪 ID |
|
||||
| `success` | Boolean | `code=200` 时为 `true` |
|
||||
|
||||
### 5.2 `data[]` 字典项字段
|
||||
|
||||
| 字段 | 类型 | 可为空 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `dictDataId` | Long | 否 | 字典数据 ID |
|
||||
| `dictType` | String | 否 | 字典类型编码,本接口固定为 `settlement_ticket_spec` |
|
||||
| `dictLabel` | String | 否 | 展示文案 |
|
||||
| `dictValue` | String | 否 | 提交值;选择后写入 Step2 `items[].specName` |
|
||||
| `icon` | String | 是 | 图标,本字典项当前为 `null` |
|
||||
| `color` | String | 是 | 展示色值,本字典项当前为 `null` |
|
||||
| `sortOrder` | Integer | 否 | 排序号,越小越靠前 |
|
||||
| `status` | String | 否 | 字典项状态 |
|
||||
| `remark` | String | 是 | 字典项说明 |
|
||||
| `createdAt` | String | 是 | 创建时间,格式为 `yyyy-MM-dd HH:mm:ss` |
|
||||
| `updatedAt` | String | 是 | 更新时间,格式为 `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
## 6. 枚举 / 数据字典
|
||||
|
||||
### 6.1 `settlement_ticket_spec`
|
||||
|
||||
**展示字段**:`dictLabel` | **提交字段**:`dictValue` | **提交目标**:Step2 `items[].specName`
|
||||
|
||||
| `dictValue` | `dictLabel` | `sortOrder` | `status` | 说明 |
|
||||
|-------------|-------------|-------------|----------|------|
|
||||
| `成人票` | 成人票 | `10` | `ACTIVE` | Step2 默认票种/规格 |
|
||||
|
||||
### 6.2 `status`
|
||||
|
||||
| 值 | 中文 | 是否由本接口返回 |
|
||||
|----|------|------------------|
|
||||
| `ACTIVE` | 启用 | 是 |
|
||||
| `INACTIVE` | 禁用 | 否;查询接口会过滤禁用项 |
|
||||
|
||||
## 7. 错误码
|
||||
|
||||
| code | 含义 | 触发场景 |
|
||||
|------|------|----------|
|
||||
| `200` | 成功 | 查询成功;没有匹配项时 `data=[]` |
|
||||
| `401` | 未认证或认证失效 | 未携带有效管理后台 JWT |
|
||||
| `500` | 系统异常 | 查询过程发生未预期异常 |
|
||||
|
||||
## 8. 示例
|
||||
|
||||
### 8.1 典型成功
|
||||
|
||||
**请求**:
|
||||
|
||||
```http
|
||||
GET /admin/dict/data/settlement_ticket_spec
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": [
|
||||
{
|
||||
"dictDataId": 101411,
|
||||
"dictType": "settlement_ticket_spec",
|
||||
"dictLabel": "成人票",
|
||||
"dictValue": "成人票",
|
||||
"icon": null,
|
||||
"color": null,
|
||||
"sortOrder": 10,
|
||||
"status": "ACTIVE",
|
||||
"remark": "Step2 默认票种/规格",
|
||||
"createdAt": "2026-07-23 10:00:00",
|
||||
"updatedAt": "2026-07-23 10:00:00"
|
||||
}
|
||||
],
|
||||
"traceId": "a1b2c3d4-e5f6-7890",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 边界情况:不存在的字典类型
|
||||
|
||||
**请求**:
|
||||
|
||||
```http
|
||||
GET /admin/dict/data/not_exists
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
无请求体。
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": [],
|
||||
"traceId": "b2c3d4e5-f6a7-8901",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 业务失败:未认证
|
||||
|
||||
**请求**:
|
||||
|
||||
```http
|
||||
GET /admin/dict/data/settlement_ticket_spec
|
||||
```
|
||||
|
||||
无请求体,且未携带 `Authorization`。
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 401,
|
||||
"message": "未认证或登录已失效",
|
||||
"data": null,
|
||||
"traceId": "c3d4e5f6-a7b8-9012",
|
||||
"success": false
|
||||
}
|
||||
```
|
||||
|
||||
## 9. 业务边界
|
||||
|
||||
- 字典接口只返回启用项;禁用项不会出现在下拉列表中。
|
||||
- 前端展示 `dictLabel`,并将选中项的 `dictValue` 原样提交到 Step2 `items[].specName`。
|
||||
- 当前首个字典值为“成人票”;后续新增启用项时,接口会按排序规则一并返回。
|
||||
- 字典为单层平铺列表,不包含父子层级。
|
||||
|
||||
## 10. 修改前后对比
|
||||
|
||||
### 10.1 字段级对比
|
||||
|
||||
| 项目 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| 响应结构 | `Result<List<SysDictDataRespVO>>` | 不变 |
|
||||
| Step2 票种规格字典类型 | 无 `settlement_ticket_spec` 可用项 | 返回启用项“成人票” |
|
||||
|
||||
### 10.2 行为级对比
|
||||
|
||||
| 行为 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| 加载 Step2 票种/规格选项 | 无专用字典数据 | 可查询 `settlement_ticket_spec` |
|
||||
| 前端选项值 | 需要自行维护 | 使用接口返回的 `dictValue` |
|
||||
|
||||
## 11. 影响评估
|
||||
|
||||
- **是否破坏向后兼容**:否,接口路径和响应结构未改变。
|
||||
- **前端是否必须同步上线**:否;接入后可使用动态字典,旧逻辑不会因本次新增字典项而报错。
|
||||
|
||||
## 12. 注意事项
|
||||
|
||||
- 不要把“成人票”选项数组硬编码在前端;应按需调用本接口。
|
||||
- 展示使用 `dictLabel`,保存使用 `dictValue`,不要提交 `dictDataId`。
|
||||
- Step2 仍使用原有 `specName` 字段,没有新增 `specCode`。
|
||||
|
||||
## 13. 关联 / 联系人
|
||||
|
||||
### 13.1 链接
|
||||
|
||||
- **Issue**: [#5185](https://git.1814.love:8443/wx/HL/issues/5185)
|
||||
- **PR**: [#5191](https://git.1814.love:8443/wx/HL/pulls/5191)
|
||||
- **Merge commit**: [03ab19b46](https://git.1814.love:8443/wx/HL/commit/03ab19b463be4b00f92848cb348ce6158918e041)
|
||||
|
||||
### 13.2 联系人
|
||||
|
||||
- **后端负责人**: @yst
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "hl-api-changelog-quality-gates",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/validate-changelog-filenames.test.mjs tests/validate-changelog-frontmatter.test.mjs",
|
||||
"check:filenames": "node scripts/validate-changelog-filenames.mjs",
|
||||
"check:frontmatter": "node scripts/validate-changelog-frontmatter.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const CONTROLLED_ROOTS = new Map([
|
||||
['changelogs-v2', '管理后台'],
|
||||
['changelogs-v2-mp', '小程序端'],
|
||||
]);
|
||||
const CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口']);
|
||||
|
||||
function ruleError(code, path, message, expected) {
|
||||
return { code, path, message, expected };
|
||||
}
|
||||
|
||||
export function getShanghaiDate(now = new Date()) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
|
||||
throw new TypeError('The validation clock must be a valid Date.');
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(now);
|
||||
const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
|
||||
if (!/^\d{4}$/.test(values.year) || !/^\d{2}$/.test(values.month) || !/^\d{2}$/.test(values.day)) {
|
||||
throw new Error('Unable to derive the current Asia/Shanghai calendar date.');
|
||||
}
|
||||
|
||||
return {
|
||||
yearMonth: `${values.year}-${values.month}`,
|
||||
day: values.day,
|
||||
isoDate: `${values.year}-${values.month}-${values.day}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNameStatusZ(raw) {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw ?? '');
|
||||
const tokens = text.split('\0');
|
||||
if (tokens.at(-1) === '') {
|
||||
tokens.pop();
|
||||
}
|
||||
|
||||
const records = [];
|
||||
for (let index = 0; index < tokens.length;) {
|
||||
const status = tokens[index++];
|
||||
if (!status) {
|
||||
throw new Error('Malformed git diff: an empty status token was found.');
|
||||
}
|
||||
|
||||
if (/^[RC]\d{1,3}$/.test(status)) {
|
||||
const sourcePath = tokens[index++];
|
||||
const targetPath = tokens[index++];
|
||||
if (sourcePath === undefined || targetPath === undefined) {
|
||||
throw new Error(`Malformed git diff: ${status} must contain source and target paths.`);
|
||||
}
|
||||
records.push({ status, sourcePath, targetPath });
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetPath = tokens[index++];
|
||||
if (targetPath === undefined) {
|
||||
throw new Error(`Malformed git diff: ${status} is missing its path.`);
|
||||
}
|
||||
records.push({ status, targetPath });
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
export function collectNewTargetPaths(records) {
|
||||
return records
|
||||
.filter(({ status }) => status === 'A' || /^C\d{1,3}$/.test(status) || /^R\d{1,3}$/.test(status))
|
||||
.map(({ targetPath }) => targetPath);
|
||||
}
|
||||
|
||||
export function controlledRootForPath(inputPath) {
|
||||
const candidate = String(inputPath);
|
||||
for (const root of CONTROLLED_ROOTS.keys()) {
|
||||
if (candidate === root || candidate.startsWith(`${root}/`) || candidate.startsWith(`${root}\\`)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateChangelogPath(inputPath, now = new Date()) {
|
||||
const changelogPath = String(inputPath);
|
||||
const root = controlledRootForPath(changelogPath);
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
const expectedClient = CONTROLLED_ROOTS.get(root);
|
||||
if (changelogPath.includes('\\')) {
|
||||
return [ruleError(
|
||||
'E_STRUCTURE',
|
||||
changelogPath,
|
||||
'受控 Git 路径包含反斜杠;反斜杠是文件名字节,不是路径分隔符',
|
||||
`${root}/YYYY-MM/DD_issue_标题-{新增接口|修改接口|删除接口}-${expectedClient}.md`,
|
||||
)];
|
||||
}
|
||||
|
||||
const expectedDate = getShanghaiDate(now);
|
||||
const errors = [];
|
||||
const segments = changelogPath.split('/');
|
||||
if (segments.length !== 3 || !segments[2].endsWith('.md')) {
|
||||
return [ruleError(
|
||||
'E_STRUCTURE',
|
||||
changelogPath,
|
||||
'路径层级或扩展名不符合规则',
|
||||
`${root}/YYYY-MM/DD_issue_标题-{新增接口|修改接口|删除接口}-${expectedClient}.md`,
|
||||
)];
|
||||
}
|
||||
|
||||
const [, yearMonth, filename] = segments;
|
||||
if (yearMonth !== expectedDate.yearMonth) {
|
||||
errors.push(ruleError(
|
||||
'E_MONTH',
|
||||
changelogPath,
|
||||
`月目录为 ${yearMonth}`,
|
||||
`Asia/Shanghai 当天的 ${expectedDate.yearMonth}`,
|
||||
));
|
||||
}
|
||||
|
||||
const stem = filename.slice(0, -3);
|
||||
const clientSeparator = stem.lastIndexOf('-');
|
||||
const actualClient = clientSeparator >= 0 ? stem.slice(clientSeparator + 1) : '';
|
||||
const beforeClient = clientSeparator >= 0 ? stem.slice(0, clientSeparator) : stem;
|
||||
if (actualClient !== expectedClient) {
|
||||
errors.push(ruleError(
|
||||
'E_CLIENT',
|
||||
changelogPath,
|
||||
`端类型为 ${actualClient || '(缺失)'}`,
|
||||
expectedClient,
|
||||
));
|
||||
}
|
||||
|
||||
const typeSeparator = beforeClient.lastIndexOf('-');
|
||||
const actualType = typeSeparator >= 0 ? beforeClient.slice(typeSeparator + 1) : '';
|
||||
const prefix = typeSeparator >= 0 ? beforeClient.slice(0, typeSeparator) : beforeClient;
|
||||
if (!CHANGE_TYPES.has(actualType)) {
|
||||
errors.push(ruleError(
|
||||
'E_CHANGE_TYPE',
|
||||
changelogPath,
|
||||
`变更类型为 ${actualType || '(缺失)'}`,
|
||||
'新增接口 / 修改接口 / 删除接口',
|
||||
));
|
||||
}
|
||||
|
||||
const prefixMatch = /^(\d{2})_([^_]*)_(.*)$/.exec(prefix);
|
||||
if (!prefixMatch) {
|
||||
const dayMatch = /^(\d{2})_/.exec(prefix);
|
||||
if (dayMatch && dayMatch[1] !== expectedDate.day) {
|
||||
errors.push(ruleError('E_DAY', changelogPath, `日前缀为 ${dayMatch[1]}`, expectedDate.day));
|
||||
}
|
||||
errors.push(ruleError(
|
||||
'E_ISSUE',
|
||||
changelogPath,
|
||||
'文件名缺少可识别的正整数 Issue 号',
|
||||
'DD_[1-9][0-9]*_标题',
|
||||
));
|
||||
return errors;
|
||||
}
|
||||
|
||||
const [, day, issue, title] = prefixMatch;
|
||||
if (day !== expectedDate.day) {
|
||||
errors.push(ruleError('E_DAY', changelogPath, `日前缀为 ${day}`, expectedDate.day));
|
||||
}
|
||||
if (!/^[1-9]\d*$/.test(issue)) {
|
||||
errors.push(ruleError('E_ISSUE', changelogPath, `Issue 号为 ${issue || '(缺失)'}`, '不带 # 的十进制正整数'));
|
||||
}
|
||||
if (title.trim() === '') {
|
||||
errors.push(ruleError('E_TITLE', changelogPath, '业务标题为空', '非空业务标题'));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function runValidation(records, now = new Date()) {
|
||||
const targetPaths = collectNewTargetPaths(records);
|
||||
const controlledPaths = targetPaths.filter((targetPath) => controlledRootForPath(targetPath));
|
||||
const errors = controlledPaths.flatMap((targetPath) => validateChangelogPath(targetPath, now));
|
||||
return {
|
||||
targetCount: targetPaths.length,
|
||||
checkedCount: controlledPaths.length,
|
||||
errors,
|
||||
expectedDate: getShanghaiDate(now),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseArguments(argv) {
|
||||
const options = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const flag = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!['--event', '--base', '--head'].includes(flag) || value === undefined) {
|
||||
throw new Error(`Unsupported or incomplete argument: ${flag ?? '(missing)'}`);
|
||||
}
|
||||
options[flag.slice(2)] = value;
|
||||
}
|
||||
if (options.event && (options.base || options.head)) {
|
||||
throw new Error('--event cannot be combined with --base/--head.');
|
||||
}
|
||||
if (!options.event && (!options.base || !options.head)) {
|
||||
throw new Error('Use --event <path> or both --base <ref> --head <ref>.');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
return execFileSync('git', args, {
|
||||
...options,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCommitRef(ref, label) {
|
||||
if (typeof ref !== 'string' || ref.length === 0 || /[\0\r\n]/.test(ref)) {
|
||||
throw new Error(`${label} must be a non-empty Git ref without control characters.`);
|
||||
}
|
||||
let resolved;
|
||||
try {
|
||||
resolved = runGit(['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
throw new Error(`${label} does not resolve to an existing commit.`);
|
||||
}
|
||||
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(resolved)) {
|
||||
throw new Error(`${label} resolved to an unexpected object ID.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveEventCommitSha(sha, label) {
|
||||
if (typeof sha !== 'string' || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(sha)) {
|
||||
throw new Error(`${label} must be a full 40- or 64-character hexadecimal commit SHA.`);
|
||||
}
|
||||
return resolveCommitRef(sha.toLowerCase(), label);
|
||||
}
|
||||
|
||||
function emptyTreeObjectId() {
|
||||
const objectId = runGit(['hash-object', '-t', 'tree', '--stdin'], {
|
||||
input: Buffer.alloc(0),
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(objectId)) {
|
||||
throw new Error('Git returned an unexpected empty-tree object ID.');
|
||||
}
|
||||
return objectId;
|
||||
}
|
||||
|
||||
function gitDiff(revisions) {
|
||||
return runGit(['diff', '--name-status', '-z', '--find-renames', ...revisions, '--'], {
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
export function diffFromOptions(options) {
|
||||
if (!options.event) {
|
||||
const base = resolveCommitRef(options.base, 'base ref');
|
||||
const head = resolveCommitRef(options.head, 'head ref');
|
||||
return gitDiff([`${base}...${head}`]);
|
||||
}
|
||||
|
||||
const event = JSON.parse(readFileSync(options.event, 'utf8'));
|
||||
if (event.pull_request) {
|
||||
const base = event.pull_request.base?.sha;
|
||||
const head = event.pull_request.head?.sha;
|
||||
if (!base || !head) {
|
||||
throw new Error('The pull_request event is missing base/head SHA values.');
|
||||
}
|
||||
const resolvedBase = resolveEventCommitSha(base, 'pull_request base SHA');
|
||||
const resolvedHead = resolveEventCommitSha(head, 'pull_request head SHA');
|
||||
return gitDiff([`${resolvedBase}...${resolvedHead}`]);
|
||||
}
|
||||
|
||||
if (event.before && event.after) {
|
||||
if (typeof event.before !== 'string' || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(event.before)) {
|
||||
throw new Error('push before SHA must be a full 40- or 64-character hexadecimal value.');
|
||||
}
|
||||
const before = /^0+$/.test(event.before)
|
||||
? emptyTreeObjectId()
|
||||
: resolveEventCommitSha(event.before, 'push before SHA');
|
||||
const after = resolveEventCommitSha(event.after, 'push after SHA');
|
||||
return gitDiff([before, after]);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported event payload: expected pull_request or push before/after SHA values.');
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
try {
|
||||
const options = parseArguments(argv);
|
||||
const records = parseNameStatusZ(diffFromOptions(options));
|
||||
const result = runValidation(records, new Date());
|
||||
if (result.errors.length > 0) {
|
||||
for (const error of result.errors) {
|
||||
console.error(`[${error.code}] ${error.path}: ${error.message}; expected ${error.expected}`);
|
||||
}
|
||||
console.error(`FAIL: found ${new Set(result.errors.map(({ path }) => path)).size} invalid changelog path(s) among ${result.checkedCount} controlled changelog path(s).`);
|
||||
return 1;
|
||||
}
|
||||
console.log(`PASS: validated ${result.checkedCount} controlled changelog path(s) from ${result.targetCount} new target path(s); expected Shanghai date ${result.expectedDate.isoDate}.`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
||||
if (isCli) {
|
||||
process.exitCode = main();
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
controlledRootForPath,
|
||||
diffFromOptions,
|
||||
parseArguments,
|
||||
parseNameStatusZ,
|
||||
} from './validate-changelog-filenames.mjs';
|
||||
|
||||
const FRONTEND_STATUSES = new Set([
|
||||
'not_required',
|
||||
'pending',
|
||||
'claimed',
|
||||
'implemented',
|
||||
'released',
|
||||
'verified',
|
||||
]);
|
||||
const BACKEND_STATUSES = new Set(['pending', 'tested', 'deployed']);
|
||||
const GATEWAY_STATUSES = new Set(['pending', 'verified', 'not_required']);
|
||||
const CONSUMERS = new Set(['admin', 'mp', 'internal', 'multiple']);
|
||||
const CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口']);
|
||||
const REQUIRED_KEYS = [
|
||||
'schema',
|
||||
'ticket',
|
||||
'title',
|
||||
'consumer',
|
||||
'change_type',
|
||||
'backend_status',
|
||||
'gateway_status',
|
||||
'frontend_status',
|
||||
'frontend_owner',
|
||||
'frontend_ref',
|
||||
'target_release',
|
||||
'verified_at',
|
||||
'updated_at',
|
||||
'base',
|
||||
];
|
||||
|
||||
function ruleError(code, file, message) {
|
||||
return { code, path: file, message };
|
||||
}
|
||||
|
||||
function isIsoDate(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const parsed = new Date(Date.UTC(year, month - 1, day));
|
||||
return parsed.getUTCFullYear() === year
|
||||
&& parsed.getUTCMonth() === month - 1
|
||||
&& parsed.getUTCDate() === day;
|
||||
}
|
||||
|
||||
function isIsoDateOrTime(value) {
|
||||
if (isIsoDate(value)) {
|
||||
return true;
|
||||
}
|
||||
return /^\d{4}-\d{2}-\d{2}T/.test(value ?? '')
|
||||
&& Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
export function parseFrontmatter(text) {
|
||||
const value = String(text ?? '').replaceAll('\r\n', '\n');
|
||||
if (!value.startsWith('---\n')) {
|
||||
return { metadata: undefined, body: value };
|
||||
}
|
||||
const end = value.indexOf('\n---\n', 4);
|
||||
if (end < 0) {
|
||||
return { metadata: undefined, body: value };
|
||||
}
|
||||
const metadata = {};
|
||||
for (const line of value.slice(4, end).split('\n')) {
|
||||
const separator = line.indexOf(':');
|
||||
if (separator < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = line.slice(0, separator).trim();
|
||||
let fieldValue = line.slice(separator + 1).trim();
|
||||
if (
|
||||
(fieldValue.startsWith('"') && fieldValue.endsWith('"'))
|
||||
|| (fieldValue.startsWith("'") && fieldValue.endsWith("'"))
|
||||
) {
|
||||
fieldValue = fieldValue.slice(1, -1);
|
||||
}
|
||||
metadata[key] = fieldValue;
|
||||
}
|
||||
return { metadata, body: value.slice(end + 5) };
|
||||
}
|
||||
|
||||
export function validateFrontendState(metadata) {
|
||||
const errors = [];
|
||||
const status = metadata.frontend_status;
|
||||
const owner = metadata.frontend_owner?.trim() ?? '';
|
||||
const reference = metadata.frontend_ref?.trim() ?? '';
|
||||
const release = metadata.target_release?.trim() ?? '';
|
||||
const verifiedAt = metadata.verified_at?.trim() ?? '';
|
||||
if (!FRONTEND_STATUSES.has(status)) {
|
||||
return [`frontend_status 非法: ${status || '(空)'}`];
|
||||
}
|
||||
if (['claimed', 'implemented', 'released', 'verified'].includes(status) && !owner) {
|
||||
errors.push(`${status} 必须填写 frontend_owner`);
|
||||
}
|
||||
if (['implemented', 'released', 'verified'].includes(status) && !reference) {
|
||||
errors.push(`${status} 必须填写 frontend_ref`);
|
||||
}
|
||||
if (['released', 'verified'].includes(status) && !release) {
|
||||
errors.push(`${status} 必须填写 target_release`);
|
||||
}
|
||||
if (status === 'verified' && !verifiedAt) {
|
||||
errors.push('verified 必须填写 verified_at');
|
||||
}
|
||||
if (verifiedAt && !isIsoDateOrTime(verifiedAt)) {
|
||||
errors.push('verified_at 必须是 ISO 日期或时间');
|
||||
}
|
||||
if (status === 'not_required' && [owner, reference, release, verifiedAt].some(Boolean)) {
|
||||
errors.push('not_required 不得保留前端负责人、引用、版本或验证时间');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateFrontendTransition(current, target, reason = '') {
|
||||
if (!FRONTEND_STATUSES.has(current) || !FRONTEND_STATUSES.has(target)) {
|
||||
return ['frontend_status 非法'];
|
||||
}
|
||||
if (current === target) {
|
||||
return [];
|
||||
}
|
||||
if (current === 'not_required' || target === 'not_required') {
|
||||
return reason.trim() ? [] : ['涉及 not_required 的迁移必须填写原因'];
|
||||
}
|
||||
const order = ['pending', 'claimed', 'implemented', 'released', 'verified'];
|
||||
const currentIndex = order.indexOf(current);
|
||||
const targetIndex = order.indexOf(target);
|
||||
if (targetIndex === currentIndex + 1) {
|
||||
return [];
|
||||
}
|
||||
if (targetIndex < currentIndex) {
|
||||
return reason.trim() ? [] : ['状态回退必须填写原因'];
|
||||
}
|
||||
return [`禁止跨级迁移: ${current} -> ${target}`];
|
||||
}
|
||||
|
||||
export function validateV2Document(file, text, { requireV2 = false } = {}) {
|
||||
const { metadata, body } = parseFrontmatter(text);
|
||||
if (!metadata) {
|
||||
return requireV2 ? [ruleError('E_FRONTMATTER', file, '新增 changelog 缺少 YAML Front Matter')] : [];
|
||||
}
|
||||
if (metadata.schema !== 'hl-changelog/v2') {
|
||||
return requireV2
|
||||
? [ruleError('E_SCHEMA', file, `新增 changelog 必须使用 hl-changelog/v2,当前为 ${metadata.schema || '(空)'}`)]
|
||||
: [];
|
||||
}
|
||||
const errors = [];
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
if (!(key in metadata)) {
|
||||
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
|
||||
}
|
||||
}
|
||||
for (const key of ['ticket', 'title', 'consumer', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
|
||||
if (!metadata[key]?.trim()) {
|
||||
errors.push(ruleError('E_REQUIRED', file, `${key} 不能为空`));
|
||||
}
|
||||
}
|
||||
if (!CHANGE_TYPES.has(metadata.change_type)) {
|
||||
errors.push(ruleError('E_CHANGE_TYPE', file, `change_type 非法: ${metadata.change_type || '(空)'}`));
|
||||
}
|
||||
if (!CONSUMERS.has(metadata.consumer)) {
|
||||
errors.push(ruleError('E_CONSUMER', file, `consumer 非法: ${metadata.consumer || '(空)'}`));
|
||||
}
|
||||
if (!BACKEND_STATUSES.has(metadata.backend_status)) {
|
||||
errors.push(ruleError('E_BACKEND_STATUS', file, `backend_status 非法: ${metadata.backend_status || '(空)'}`));
|
||||
}
|
||||
if (!GATEWAY_STATUSES.has(metadata.gateway_status)) {
|
||||
errors.push(ruleError('E_GATEWAY_STATUS', file, `gateway_status 非法: ${metadata.gateway_status || '(空)'}`));
|
||||
}
|
||||
if (metadata.backend_status !== 'deployed') {
|
||||
errors.push(ruleError('E_BACKEND_PENDING', file, '发布的 changelog 必须是 backend_status=deployed'));
|
||||
}
|
||||
if (metadata.gateway_status === 'pending') {
|
||||
errors.push(ruleError('E_GATEWAY_PENDING', file, '发布的 changelog 不能保留 gateway_status=pending'));
|
||||
}
|
||||
for (const message of validateFrontendState(metadata)) {
|
||||
errors.push(ruleError('E_FRONTEND_STATE', file, message));
|
||||
}
|
||||
if (metadata.consumer === 'internal' && metadata.frontend_status !== 'not_required') {
|
||||
errors.push(ruleError('E_FRONTEND_STATE', file, 'internal consumer 必须使用 frontend_status=not_required'));
|
||||
}
|
||||
if (!isIsoDate(metadata.updated_at)) {
|
||||
errors.push(ruleError('E_UPDATED_AT', file, 'updated_at 必须是真实的 YYYY-MM-DD 日期'));
|
||||
}
|
||||
const filename = path.posix.basename(file);
|
||||
const issue = /^\d{2}_([1-9]\d*)_/.exec(filename)?.[1];
|
||||
if (issue && metadata.ticket !== issue) {
|
||||
errors.push(ruleError('E_TICKET_MISMATCH', file, `ticket=${metadata.ticket} 与文件名 Issue=${issue} 不一致`));
|
||||
}
|
||||
const filenameType = /-(新增接口|修改接口|删除接口)-(?:管理后台|小程序端)\.md$/.exec(filename)?.[1];
|
||||
if (filenameType && metadata.change_type !== filenameType) {
|
||||
errors.push(ruleError('E_TYPE_MISMATCH', file, `change_type=${metadata.change_type} 与文件名=${filenameType} 不一致`));
|
||||
}
|
||||
if (/\{[^{}\n]+\}|\bTODO\b|待补充/i.test(body)) {
|
||||
errors.push(ruleError('E_PLACEHOLDER', file, '正文仍有 TODO、待补充或模板占位符'));
|
||||
}
|
||||
if (!body.includes('## 变更接口') || !body.includes('## 验证证据')) {
|
||||
errors.push(ruleError('E_SECTIONS', file, '正文缺少“变更接口”或“验证证据”章节'));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function collectChangedDocuments(records) {
|
||||
return records
|
||||
.filter(({ status }) => status !== 'D')
|
||||
.map((record) => ({
|
||||
path: record.targetPath,
|
||||
isNew: record.status === 'A' || /^C\d{1,3}$/.test(record.status) || /^R\d{1,3}$/.test(record.status),
|
||||
}))
|
||||
.filter(({ path: file }) => controlledRootForPath(file) && file.endsWith('.md'));
|
||||
}
|
||||
|
||||
export function runFrontmatterValidation(records, root = process.cwd()) {
|
||||
const documents = collectChangedDocuments(records);
|
||||
const errors = [];
|
||||
for (const document of documents) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path.join(root, ...document.path.split('/')), 'utf8');
|
||||
} catch (error) {
|
||||
errors.push(ruleError('E_READ', document.path, `无法读取文件: ${error.message}`));
|
||||
continue;
|
||||
}
|
||||
errors.push(...validateV2Document(document.path, text, { requireV2: document.isNew }));
|
||||
}
|
||||
return { checkedCount: documents.length, errors };
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
try {
|
||||
const options = parseArguments(argv);
|
||||
const records = parseNameStatusZ(diffFromOptions(options));
|
||||
const result = runFrontmatterValidation(records);
|
||||
if (result.errors.length > 0) {
|
||||
for (const error of result.errors) {
|
||||
console.error(`[${error.code}] ${error.path}: ${error.message}`);
|
||||
}
|
||||
console.error(`FAIL: ${result.errors.length} frontmatter error(s) in ${result.checkedCount} changelog file(s).`);
|
||||
return 1;
|
||||
}
|
||||
console.log(`PASS: validated frontmatter for ${result.checkedCount} changed changelog file(s).`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
||||
if (isCli) {
|
||||
process.exitCode = main();
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
collectNewTargetPaths,
|
||||
getShanghaiDate,
|
||||
parseNameStatusZ,
|
||||
runValidation,
|
||||
validateChangelogPath,
|
||||
} from '../scripts/validate-changelog-filenames.mjs';
|
||||
|
||||
const SHANGHAI_NOW = new Date('2026-07-22T04:00:00.000Z');
|
||||
const CLI_PATH = fileURLToPath(new URL('../scripts/validate-changelog-filenames.mjs', import.meta.url));
|
||||
|
||||
function validAdmin({ day = '22', month = '2026-07', issue = '5161', title = '文件名校验', type = '新增接口', client = '管理后台' } = {}) {
|
||||
return `changelogs-v2/${month}/${day}_${issue}_${title}-${type}-${client}.md`;
|
||||
}
|
||||
|
||||
function validMp({ day = '22', month = '2026-07', issue = '5161', title = '文件名校验', type = '修改接口', client = '小程序端' } = {}) {
|
||||
return `changelogs-v2-mp/${month}/${day}_${issue}_${title}-${type}-${client}.md`;
|
||||
}
|
||||
|
||||
function codes(file, now = SHANGHAI_NOW) {
|
||||
return validateChangelogPath(file, now).map((error) => error.code);
|
||||
}
|
||||
|
||||
test('accepts a valid v2 admin path', () => {
|
||||
assert.deepEqual(validateChangelogPath(validAdmin(), SHANGHAI_NOW), []);
|
||||
});
|
||||
|
||||
test('accepts a valid v2-mp path', () => {
|
||||
assert.deepEqual(validateChangelogPath(validMp(), SHANGHAI_NOW), []);
|
||||
});
|
||||
|
||||
test('uses Asia/Shanghai when UTC is still the previous day', () => {
|
||||
const now = new Date('2026-07-21T16:05:00.000Z');
|
||||
assert.equal(getShanghaiDate(now).isoDate, '2026-07-22');
|
||||
assert.deepEqual(validateChangelogPath(validAdmin(), now), []);
|
||||
});
|
||||
|
||||
test('accepts the real leap day', () => {
|
||||
const now = new Date('2024-02-29T04:00:00.000Z');
|
||||
assert.deepEqual(validateChangelogPath(validAdmin({ month: '2024-02', day: '29' }), now), []);
|
||||
});
|
||||
|
||||
test('rejects a wrong day', () => {
|
||||
assert.ok(codes(validAdmin({ day: '21' })).includes('E_DAY'));
|
||||
});
|
||||
|
||||
test('rejects a wrong month directory', () => {
|
||||
assert.ok(codes(validAdmin({ month: '2026-06' })).includes('E_MONTH'));
|
||||
});
|
||||
|
||||
test('rejects a day greater than 31', () => {
|
||||
assert.ok(codes(validAdmin({ day: '63' })).includes('E_DAY'));
|
||||
});
|
||||
|
||||
test('rejects a missing issue number', () => {
|
||||
assert.ok(codes('changelogs-v2/2026-07/22_标题-新增接口-管理后台.md').includes('E_ISSUE'));
|
||||
});
|
||||
|
||||
test('rejects issue zero', () => {
|
||||
assert.ok(codes(validAdmin({ issue: '0' })).includes('E_ISSUE'));
|
||||
});
|
||||
|
||||
test('rejects a hash-prefixed issue', () => {
|
||||
assert.ok(codes(validAdmin({ issue: '#5161' })).includes('E_ISSUE'));
|
||||
});
|
||||
|
||||
test('rejects a non-numeric issue', () => {
|
||||
assert.ok(codes(validAdmin({ issue: 'ABC' })).includes('E_ISSUE'));
|
||||
});
|
||||
|
||||
test('rejects an unsupported change type', () => {
|
||||
assert.ok(codes(validAdmin({ type: '新增字段' })).includes('E_CHANGE_TYPE'));
|
||||
});
|
||||
|
||||
test('rejects the mini-program client in v2', () => {
|
||||
assert.ok(codes(validAdmin({ client: '小程序端' })).includes('E_CLIENT'));
|
||||
});
|
||||
|
||||
test('rejects the admin client in v2-mp', () => {
|
||||
assert.ok(codes(validMp({ client: '管理后台' })).includes('E_CLIENT'));
|
||||
});
|
||||
|
||||
test('rejects an empty title', () => {
|
||||
assert.ok(codes(validAdmin({ title: '' })).includes('E_TITLE'));
|
||||
});
|
||||
|
||||
test('rejects a backslash inside a controlled physical path', () => {
|
||||
const physicalPath = 'changelogs-v2/2026-07\\22_5161_绕过-新增接口-管理后台.md';
|
||||
assert.ok(codes(physicalPath).includes('E_STRUCTURE'));
|
||||
});
|
||||
|
||||
test('rejects a root-level backslash pseudo path', () => {
|
||||
const physicalPath = 'changelogs-v2\\2026-07\\22_5161_绕过-新增接口-管理后台.md';
|
||||
assert.ok(codes(physicalPath).includes('E_STRUCTURE'));
|
||||
});
|
||||
|
||||
test('exempts phase-one changelogs', () => {
|
||||
assert.deepEqual(validateChangelogPath('changelogs/2026-07/99_no_issue_anything.md', SHANGHAI_NOW), []);
|
||||
});
|
||||
|
||||
test('parses NUL-delimited Chinese and space-containing paths', () => {
|
||||
const raw = Buffer.from(`A\0${validAdmin({ title: '中文 空格' })}\0M\0old file.md\0`, 'utf8');
|
||||
assert.deepEqual(parseNameStatusZ(raw), [
|
||||
{ status: 'A', targetPath: validAdmin({ title: '中文 空格' }) },
|
||||
{ status: 'M', targetPath: 'old file.md' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('exempts modified and deleted historical bad paths', () => {
|
||||
const records = parseNameStatusZ(Buffer.from(
|
||||
'M\0changelogs-v2/2026-07/63_历史坏文件.md\0D\0changelogs-v2/2026-07/99_无issue.md\0',
|
||||
'utf8',
|
||||
));
|
||||
assert.deepEqual(collectNewTargetPaths(records), []);
|
||||
assert.equal(runValidation(records, SHANGHAI_NOW).errors.length, 0);
|
||||
});
|
||||
|
||||
test('validates only a copy target path', () => {
|
||||
const records = parseNameStatusZ(Buffer.from(`C100\0old.md\0${validAdmin()}\0`, 'utf8'));
|
||||
assert.deepEqual(collectNewTargetPaths(records), [validAdmin()]);
|
||||
});
|
||||
|
||||
test('accepts a rename from an old bad name to a valid target', () => {
|
||||
const records = parseNameStatusZ(Buffer.from(`R100\0changelogs-v2/2026-07/63_old.md\0${validAdmin()}\0`, 'utf8'));
|
||||
assert.equal(runValidation(records, SHANGHAI_NOW).errors.length, 0);
|
||||
});
|
||||
|
||||
test('rejects a rename target with an old day', () => {
|
||||
const target = validAdmin({ day: '21' });
|
||||
const records = parseNameStatusZ(Buffer.from(`R100\0changelogs-v2/2026-07/63_old.md\0${target}\0`, 'utf8'));
|
||||
assert.ok(runValidation(records, SHANGHAI_NOW).errors.some((error) => error.code === 'E_DAY'));
|
||||
});
|
||||
|
||||
test('aggregates all invalid paths instead of stopping at the first', () => {
|
||||
const records = [
|
||||
{ status: 'A', targetPath: validAdmin() },
|
||||
{ status: 'A', targetPath: validMp() },
|
||||
{ status: 'A', targetPath: validAdmin({ day: '21' }) },
|
||||
{ status: 'A', targetPath: validMp({ issue: '0' }) },
|
||||
];
|
||||
const result = runValidation(records, SHANGHAI_NOW);
|
||||
assert.equal(new Set(result.errors.map((error) => error.path)).size, 2);
|
||||
assert.equal(result.checkedCount, 4);
|
||||
});
|
||||
|
||||
test('real git diff ignores historical modifications and validates an added path', () => {
|
||||
const repo = mkdtempSync(path.join(tmpdir(), 'hl-changelog-gate-'));
|
||||
try {
|
||||
execFileSync('git', ['init', '-q'], { cwd: repo });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo });
|
||||
execFileSync('git', ['config', 'user.name', 'Gate Test'], { cwd: repo });
|
||||
execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: repo });
|
||||
const oldPath = path.join(repo, 'changelogs-v2', '2026-07', '63_old.md');
|
||||
mkdirSync(path.dirname(oldPath), { recursive: true });
|
||||
writeFileSync(oldPath, 'old\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repo });
|
||||
execFileSync('git', ['commit', '-qm', 'base'], { cwd: repo });
|
||||
const base = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
||||
|
||||
writeFileSync(oldPath, 'changed\n');
|
||||
const addedPath = path.join(repo, ...validAdmin().split('/'));
|
||||
writeFileSync(addedPath, 'new\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repo });
|
||||
execFileSync('git', ['commit', '-qm', 'head'], { cwd: repo });
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
||||
const raw = execFileSync('git', ['diff', '--name-status', '-z', '--find-renames', base, head], { cwd: repo });
|
||||
const result = runValidation(parseNameStatusZ(raw), SHANGHAI_NOW);
|
||||
|
||||
assert.equal(result.checkedCount, 1);
|
||||
assert.equal(result.errors.length, 0);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('production CLI selects PR/push revisions safely and preserves exit-code semantics', async (t) => {
|
||||
const repo = mkdtempSync(path.join(tmpdir(), 'hl-changelog-cli-'));
|
||||
const eventPath = path.join(repo, 'event.json');
|
||||
|
||||
function git(...args) {
|
||||
return execFileSync('git', args, { cwd: repo, encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
function runCli(args) {
|
||||
return execFileSync(process.execPath, [CLI_PATH, ...args], {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function runCliResult(args) {
|
||||
try {
|
||||
return { status: 0, stdout: runCli(args), stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: error.status,
|
||||
stdout: String(error.stdout ?? ''),
|
||||
stderr: String(error.stderr ?? ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function writeEvent(value) {
|
||||
writeFileSync(eventPath, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
}
|
||||
|
||||
try {
|
||||
git('init', '-q');
|
||||
git('config', 'user.email', 'test@example.com');
|
||||
git('config', 'user.name', 'Gate Test');
|
||||
git('config', 'core.autocrlf', 'false');
|
||||
writeFileSync(path.join(repo, 'README.md'), 'base\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'base');
|
||||
const base = git('rev-parse', 'HEAD');
|
||||
|
||||
const today = getShanghaiDate(new Date());
|
||||
const validPath = validAdmin({ month: today.yearMonth, day: today.day });
|
||||
const addedPath = path.join(repo, ...validPath.split('/'));
|
||||
mkdirSync(path.dirname(addedPath), { recursive: true });
|
||||
writeFileSync(addedPath, 'valid\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'valid');
|
||||
const validHead = git('rev-parse', 'HEAD');
|
||||
|
||||
const wrongDay = today.day === '01' ? '02' : '01';
|
||||
const invalidPath = validAdmin({ month: today.yearMonth, day: wrongDay, issue: '5162' });
|
||||
writeFileSync(path.join(repo, ...invalidPath.split('/')), 'invalid\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'invalid');
|
||||
const invalidHead = git('rev-parse', 'HEAD');
|
||||
|
||||
await t.test('PR event uses base...head and returns 0 for a legal addition', () => {
|
||||
writeEvent({ pull_request: { base: { sha: base }, head: { sha: validHead } } });
|
||||
const result = runCliResult(['--event', eventPath]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /validated 1 controlled changelog path/);
|
||||
});
|
||||
|
||||
await t.test('push event uses before/after and returns 0 for a legal addition', () => {
|
||||
writeEvent({ before: base, after: validHead });
|
||||
const result = runCliResult(['--event', eventPath]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
await t.test('new-branch push uses the empty tree', () => {
|
||||
writeEvent({ before: '0'.repeat(base.length), after: validHead });
|
||||
const result = runCliResult(['--event', eventPath]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /validated 1 controlled changelog path/);
|
||||
});
|
||||
|
||||
await t.test('rule failure returns 1', () => {
|
||||
writeEvent({ pull_request: { base: { sha: validHead }, head: { sha: invalidHead } } });
|
||||
const result = runCliResult(['--event', eventPath]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /\[E_DAY\]/);
|
||||
});
|
||||
|
||||
await t.test('missing event fields return 2', () => {
|
||||
writeEvent({ pull_request: { base: { sha: base }, head: {} } });
|
||||
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
||||
});
|
||||
|
||||
await t.test('invalid JSON returns 2', () => {
|
||||
writeEvent('{not-json');
|
||||
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
||||
});
|
||||
|
||||
await t.test('nonexistent event commit returns 2', () => {
|
||||
writeEvent({ before: base, after: 'f'.repeat(base.length) });
|
||||
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
||||
});
|
||||
|
||||
await t.test('a Git option cannot be injected as a local ref', () => {
|
||||
const result = runCliResult(['--base', '--output=gate-review', '--head', 'HEAD']);
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(existsSync(path.join(repo, 'gate-review')), false);
|
||||
assert.equal(readdirSync(repo).some((name) => name.startsWith('gate-review')), false);
|
||||
});
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
parseFrontmatter,
|
||||
runFrontmatterValidation,
|
||||
validateFrontendState,
|
||||
validateFrontendTransition,
|
||||
validateV2Document,
|
||||
} from '../scripts/validate-changelog-frontmatter.mjs';
|
||||
|
||||
|
||||
const FILE = 'changelogs-v2/2026-07/24_5218_工作流治理-修改接口-管理后台.md';
|
||||
|
||||
function metadata(overrides = {}) {
|
||||
return {
|
||||
schema: 'hl-changelog/v2',
|
||||
ticket: '5218',
|
||||
title: '工作流治理',
|
||||
consumer: 'admin',
|
||||
change_type: '修改接口',
|
||||
backend_status: 'deployed',
|
||||
gateway_status: 'verified',
|
||||
frontend_status: 'pending',
|
||||
frontend_owner: '',
|
||||
frontend_ref: '',
|
||||
target_release: '',
|
||||
verified_at: '',
|
||||
status_note: '',
|
||||
updated_at: '2026-07-24',
|
||||
base: 'dev-v3',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function document(overrides = {}) {
|
||||
const fields = metadata(overrides);
|
||||
const frontmatter = Object.entries(fields)
|
||||
.map(([key, value]) => `${key}: "${value}"`)
|
||||
.join('\n');
|
||||
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 变更接口\n\n- 无业务接口变化。\n\n## 验证证据\n\n- 自动化测试通过。\n`;
|
||||
}
|
||||
|
||||
test('parses quoted flat YAML frontmatter', () => {
|
||||
const parsed = parseFrontmatter(document());
|
||||
assert.equal(parsed.metadata.schema, 'hl-changelog/v2');
|
||||
assert.equal(parsed.metadata.frontend_status, 'pending');
|
||||
});
|
||||
|
||||
test('accepts a complete v2 handoff with pending frontend consumption', () => {
|
||||
assert.deepEqual(validateV2Document(FILE, document(), { requireV2: true }), []);
|
||||
});
|
||||
|
||||
test('rejects backend and gateway pending at publication', () => {
|
||||
const errors = validateV2Document(
|
||||
FILE,
|
||||
document({ backend_status: 'pending', gateway_status: 'pending' }),
|
||||
{ requireV2: true },
|
||||
);
|
||||
assert.ok(errors.some(({ code }) => code === 'E_BACKEND_PENDING'));
|
||||
assert.ok(errors.some(({ code }) => code === 'E_GATEWAY_PENDING'));
|
||||
});
|
||||
|
||||
test('requires frontend evidence as status advances', () => {
|
||||
assert.deepEqual(
|
||||
validateFrontendState(metadata({ frontend_status: 'claimed' })),
|
||||
['claimed 必须填写 frontend_owner'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
validateFrontendState(metadata({
|
||||
frontend_status: 'implemented',
|
||||
frontend_owner: 'frontend-team',
|
||||
frontend_ref: 'mmg/hl-ui@abc1234',
|
||||
})),
|
||||
[],
|
||||
);
|
||||
assert.ok(
|
||||
validateFrontendState(metadata({
|
||||
frontend_status: 'verified',
|
||||
frontend_owner: 'frontend-team',
|
||||
frontend_ref: 'mmg/hl-ui@abc1234',
|
||||
target_release: 'prod-2026.07.24',
|
||||
verified_at: '2026-02-30',
|
||||
})).includes('verified_at 必须是 ISO 日期或时间'),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects skipped transitions and requires a rollback reason', () => {
|
||||
assert.ok(validateFrontendTransition('pending', 'implemented').length > 0);
|
||||
assert.deepEqual(validateFrontendTransition('pending', 'claimed'), []);
|
||||
assert.ok(validateFrontendTransition('released', 'implemented').length > 0);
|
||||
assert.deepEqual(
|
||||
validateFrontendTransition('released', 'implemented', '测试发布已回滚'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('new changelog requires v2 while a modified legacy file remains compatible', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'hl-frontmatter-'));
|
||||
try {
|
||||
const file = path.join(root, ...FILE.split('/'));
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, '# legacy\n');
|
||||
const newResult = runFrontmatterValidation([{ status: 'A', targetPath: FILE }], root);
|
||||
assert.ok(newResult.errors.some(({ code }) => code === 'E_FRONTMATTER'));
|
||||
const modifiedResult = runFrontmatterValidation([{ status: 'M', targetPath: FILE }], root);
|
||||
assert.deepEqual(modifiedResult.errors, []);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detects metadata and filename mismatches', () => {
|
||||
const errors = validateV2Document(
|
||||
FILE,
|
||||
document({ ticket: '9999', change_type: '新增接口' }),
|
||||
{ requireV2: true },
|
||||
);
|
||||
assert.ok(errors.some(({ code }) => code === 'E_TICKET_MISMATCH'));
|
||||
assert.ok(errors.some(({ code }) => code === 'E_TYPE_MISMATCH'));
|
||||
});
|
||||
|
||||
test('rejects unsupported consumers and impossible dates', () => {
|
||||
const errors = validateV2Document(
|
||||
FILE,
|
||||
document({ consumer: 'browser', updated_at: '2026-02-30' }),
|
||||
{ requireV2: true },
|
||||
);
|
||||
assert.ok(errors.some(({ code }) => code === 'E_CONSUMER'));
|
||||
assert.ok(errors.some(({ code }) => code === 'E_UPDATED_AT'));
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户