From 312ecd0f1f5cfc8cb469cacd1ad2d8927b3466d0 Mon Sep 17 00:00:00 2001 From: API Changelog Bot Date: Tue, 22 Sep 2026 13:17:12 +0800 Subject: [PATCH] =?UTF-8?q?docs(changelog):=20=E5=9B=A2=E6=9C=9F=E7=94=A8?= =?UTF-8?q?=E8=BD=A6=E9=9C=80=E6=B1=82=E7=BB=93=E6=9E=84=E5=8C=96=20+=20?= =?UTF-8?q?=E5=9B=A2=E6=9C=9F=E7=AE=A1=E7=90=86=E5=91=98=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E5=AD=90=E8=AE=A2=E5=8D=95=EF=BC=8C=E4=B8=A4?= =?UTF-8?q?=E4=BB=BD=E5=89=8D=E7=AB=AF=E4=BA=A4=E6=8E=A5=E4=BB=B6=20(#8152?= =?UTF-8?q?=20#8151=20#8153=20#8154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 22_8152_…:团级用车需求补 seats/count/specialTags/remark 四个结构化字段;新增 `GET .../requirement/vehicle-households` 子订单用车需求记录端点;requirement-summary 补 transferSummary 聚合;confirm-check 补 transferSubmitEnabled 与接送机缺口名单。 - 22_8154_…:团期管理员(GROUP_BATCH_MANAGER)可只读打开团期子订单详情(10 个端点放行), 13 个金额/成本/流水面端点对该角色收回(581008),写面全域拒绝。 两份均已回填测试服活体实测读数:order-v3 @ f1986f996,user-service / fleet @ c4321f961。 #8154 的前后对照含一条关键读数——改动前该角色读订单被拒、写订单却畅通,本批一并收口。 Co-Authored-By: Claude Opus 5 (1M context) --- ...œ€求补车辆规格与接送机汇总-修改接口-管理后台.md | 1003 +++++++++++++++++ ...只读查看子订单与金额面端点收回-修复-管理后台.md | 559 +++++++++ 2 files changed, 1562 insertions(+) create mode 100644 changelogs-v2/2026-09/22_8152_团期用车需求补车辆规格与接送机汇总-修改接口-管理后台.md create mode 100644 changelogs-v2/2026-09/22_8154_团期管理员只读查看子订单与金额面端点收回-修复-管理后台.md diff --git a/changelogs-v2/2026-09/22_8152_团期用车需求补车辆规格与接送机汇总-修改接口-管理后台.md b/changelogs-v2/2026-09/22_8152_团期用车需求补车辆规格与接送机汇总-修改接口-管理后台.md new file mode 100644 index 00000000..e5b4ebdf --- /dev/null +++ b/changelogs-v2/2026-09/22_8152_团期用车需求补车辆规格与接送机汇总-修改接口-管理后台.md @@ -0,0 +1,1003 @@ +--- +schema: "hl-changelog/v2" +ticket: "8152" +title: "团期用车需求补车辆规格(座位/车辆数/特殊诉求/备注),新增子订单用车需求记录端点,汇总与预检补接送机口径" +consumer: "admin" +author: "wx(GIT)" +change_type: "修改接口" +backend_status: "deployed" +gateway_status: "not_required" +frontend_status: "pending" +frontend_owner: "" +frontend_ref: "" +target_release: "" +verified_at: "" +status_note: "一次提交覆盖三张工单:#8152 团级乘车分组补车辆规格四字段 + 三个新错误码(809116/809117/809118);#8151 新增 GET .../requirement/vehicle-households 并给 requirement-summary 补 transferSummary;#8153 给 requirement/confirm-check 补 transferSubmitEnabled 与 transferDeclaredWithoutRequirement。gateway_status: not_required —— 本批零网关改动,/v3/admin/** 通配已配在 hl-gateway application.yml 的 order-service-v3 路由上,无新增 /admin/ 前缀。frontend_status: pending —— 新增端点与新增字段均需前端渲染,后端不代填。" +updated_at: "2026-09-22" +base: "dev-v3" +--- + +# order-v3 团期需求: 团级用车需求补车辆规格,新增子订单用车需求记录端点,汇总/预检补接送机口径 + +> **存放目录**: 二期(v3) → `changelogs-v2/2026-09/` +> +> **服务**: hl-order-service-v3(团期需求域)、hl-fleet-service(配车覆盖度基线,无对外接口变化) +> **PR**: 见文末「关联 / 联系人」 +> **Issue**: #8152 / #8151 / #8153 +> **日期**: 2026-09-22 +> **影响范围**: 管理后台「团期详情 → 查看需求」Tab 的用车板块(团级正式用车需求编辑弹窗、子订单用车需求记录、全团需求汇总、整体确认预检) + +--- + +## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写) + +**一条口径不一致必须在页面上原样透出,不要自行抹平:** + +- **拦截口径不扣司机位**:`PUT .../vehicle-requirement` 的座位充足性校验判的是 `seats × count < 该组最大单日乘车人数`,**总座位里含驾驶位**。 +- **回显口径扣司机位**:响应里的 `remainingPassengerSeats` = `max(0, seats × count − count)` − 该组最大单日乘车人数,**每辆车扣 1 个司机位**。 +- 于是 **20 座 × 1 辆 / 20 人能保存成功,而返回的 `remainingPassengerSeats` 是 `-1`**。 + +**展示侧**:`remainingPassengerSeats` **必须允许负数**,不能 clamp 到 0。负值就是缺口,clamp 之后「刚好坐不下 1 人」与「刚好坐满」在页面上完全同形,运营拿不到补车信号。 + +🔴 **提交侧(更要紧的一条)**:保存成功的分组,其 `remainingPassengerSeats` **可以为负数**。这是「余座缺口」的展示值,**不是校验失败信号,前端不得据此拦截提交**。是否允许保存由后端 809116 判定(口径:`seats × count` 与该组最大乘车人数比较,不扣司机位);前端只需把负数如实显示为缺口,用于提示运营补车。 + +响应体里**没有任何字段**表示「这一单已经过了后端闸门」——`remainingPassengerSeats` 是唯一的座位信号。若前端按「负数 = 不合法」做提交拦截,`20 座 × 1 辆 / 20 人`这类组合在 UI 上就**永远提交不了**,而后端 API 是放行的:严口径事实上变成了闸门,且后端全绿、日志无异常、无人会发现。 + +--- + +## 一、背景(选填) + +团级正式用车需求原先只填车型文本,车务拿到的是「35座大巴」这种字符串,排车时「几座 × 几辆」只能靠人问;同时团期管理员在「查看需求」Tab 里看不到定制师逐户报了什么(用房侧早有「汇总 + 子订单订房记录」上下两块,用车只有团级那一块),等于对着一个汇总数字签字。本批补齐三件事:团级分组的车辆规格、子订单用车需求记录端点、接送机(TRANSFER)在汇总与预检里的口径。 + +| 维度 | 本批之前 | 本批之后 | +|------|----------|----------| +| 团级乘车分组可填规格 | 仅车型文本 | 车型 + 座位数 + 车辆数 + 特殊诉求标签 + 备注 | +| 团期「查看需求」用车板块 | 只有团级正式需求一块 | 团级正式需求 + 子订单用车需求记录(新端点) | +| `requirement-summary` 车侧口径 | 只统计行程用车(TRAVEL) | 车侧字段口径**不变**,接送机另挂 `transferSummary` | +| 整体确认预检的接送机信息 | 无 | `transferSubmitEnabled` + `transferDeclaredWithoutRequirement` 缺口名单 | + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 保存团期正式用车需求 | PUT | `/v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` | 请求体新增 4 字段 + 响应新增 8 字段 + 3 个新错误码 | 分组元素补 seats/count/specialTags/remark | +| 2 | 读团期正式用车需求 | GET | `/v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` | 响应新增 8 字段 | 与 1 共用同一响应体,原样回显 | +| 3 | 团期子订单用车需求记录 | GET | `/v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households` | 新增接口 | 形态对齐既有 hotel-households | +| 4 | 全团需求汇总 | GET | `/v3/admin/order/group-batch/{groupBatchId}/requirement-summary` | 响应新增 transferSummary | 既有车侧字段零改动 | +| 5 | 整体确认需求缺失预检 | GET | `/v3/admin/order/group-batch/{groupBatchId}/requirement/confirm-check` | 响应新增 2 字段 | 接送机开关 + 缺口名单 | + +--- + +## 三、接口详情 + +### 1. 保存团期正式用车需求 `PUT /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` + +**VO**: `GroupVehicleRequirementSaveReqVO → GroupVehicleRequirementRespVO` + +#### 使用场景 + +团期详情「查看需求 → 团级正式用车需求」编辑弹窗点保存。语义是**整份全量替换**:未出现在本次提交里的分组会被移出当前版本。本批在分组元素里新增了 4 个可填字段,其余字段与提交语义均不变。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| groupBatchId | Path | Long | ✅ | - | 团期聚合主键 | +| version | Body | Integer | ❌ | 首次保存传 null,其后回传上次拿到的值 | 乐观锁;不一致抛 809102 | +| remark | Body | String | ❌ | ≤500 | 整份需求备注 | +| groups | Body | Array | ✅ | 可以是空数组(`@NotNull` 非 `@NotEmpty`) | 全部乘车分组;有在团需车户却零分组抛 809103 | +| groups[].groupId | Body | Long | ❌ | 新增分组传 null | 带上它即声明「这就是库里那一组」,此时 groupCode 不得变更(否则 809104) | +| groups[].groupCode | Body | String | ✅ | ≤32,同一份内不得重复 | 直接作为车费 alloc_group | +| groups[].vehicleType | Body | String | ✅ | ≤64 | 车型文本/字典值,不设档位枚举 | +| groups[].serviceStartDate | Body | LocalDate | ✅ | `yyyy-MM-dd` | 本组服务开始日 | +| groups[].serviceEndDate | Body | LocalDate | ✅ | 不早于开始日 | 本组服务结束日 | +| **groups[].seats** | Body | Integer | ❌ | `@Min(1)`;与 count 同填或同空 | **本批新增** 该组单车座位数;存量分组可不传 | +| **groups[].count** | Body | Integer | ❌ | `@Min(1)`;与 seats 同填或同空 | **本批新增** 该组车辆数量;只填一半抛 809118 | +| **groups[].specialTags** | Body | Array<String> | ❌ | 取值须在字典 `vehicle_special_demand` 内 | **本批新增** 特殊诉求标签编码数组;含字典外编码整份拒绝(809117) | +| **groups[].remark** | Body | String | ❌ | ≤500 | **本批新增** 该组备注 / 其他诉求 | +| groups[].days | Body | Array | ✅ | 非空,且正好铺满本组服务日范围 | 逐日用车人数与成员 | +| groups[].days[].tripDate | Body | LocalDate | ✅ | 落在本组服务日范围内、不重复、不缺日 | 越界或重复抛 809105,缺日抛 809106 | +| groups[].days[].headcount | Body | Integer | ✅ | `@Min(1)`,且 ≥ 当日成员户数 | 该组该日**乘车人数**(不是户数) | +| groups[].days[].memberOrderIds | Body | Array<Long> | ✅ | 非空,须全属本团在团户 | 该组该日实际乘车的子订单集合 | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| requirementId | String | 正式需求主键(Long 序列化为字符串) | +| groupBatchId | String | 团期聚合主键(Long 序列化为字符串) | +| status | String | DRAFT / CONFIRMED / DISPATCHED / DONE / PENDING_RECONFIRM / CANCELLED;PUT 后为 DRAFT | +| version | Integer | 版本号,下次提交须回传 | +| remark | String | 整份备注(撤回/免车会追加,不覆盖) | +| confirmedBy | String | 整份确认人;DRAFT 时为 null | +| confirmedAt | LocalDateTime | 整份确认时间;DRAFT 时为 null | +| planRefreshState | String | 配车刷新状态原值;null = 从未登记过刷新 | +| planRefreshReplayCount | Integer | 人工受控重投累计次数,上限 5 | +| blockedStage | String | 团期被阻断阶段快照;null = 未阻断 | +| planRefreshStalled | Boolean | 刷新是否已停滞、不会自愈(恒非 null),判「要不要现在找人」看它 | +| planRefreshStalledReason | String | STATE_FAILED / COMMAND_FAILED / TIMEOUT;未停滞为 null | +| planRefreshTimeoutAt | LocalDateTime | 本轮刷新超时时刻;非 PENDING 为 null | +| planRefreshReplayExhausted | Boolean | 人工重投额度是否耗尽(恒非 null) | +| groups | Array | 全部乘车分组;整团免车态为空数组 | +| groups[].groupId | String | 分组主键(Long 序列化为字符串),下次提交同一组必须回传 | +| groups[].groupCode | String | 分组键 = 车费 alloc_group | +| groups[].vehicleType | String | 车型文本/字典值 | +| **groups[].vehicleTypeName** | String | **本批新增** 车型中文名,后端下发,前端不自己映射编码 | +| groups[].serviceStartDate / serviceEndDate | LocalDate | 本组服务日范围 | +| **groups[].seats** | Integer | **本批新增** 单车座位数;**存量分组为 null(表示待填),不是 0** | +| **groups[].count** | Integer | **本批新增** 车辆数量;存量分组为 null | +| **groups[].specialTags** | Array<SpecialTagItem> | **本批新增** 特殊诉求标签(code + name);存量分组为空数组 | +| **groups[].remark** | String | **本批新增** 该组备注;存量分组为 null | +| **groups[].totalSeatCount** | Integer | **本批新增** 总座位数 = seats × count(含驾驶位);座位或数量缺一即为 null | +| **groups[].maxHeadcount** | Integer | **本批新增** 该组 days 里的最大用车人数 | +| **groups[].remainingPassengerSeats** | Integer | **本批新增** 余座 = 扣司机位后的可乘座位 − maxHeadcount;**可为负**;座位或数量缺一即为 null | +| groups[].specialTags[].code / name | String | 字典编码 / 中文名;字典查不到该编码时 name 为 null(不回落成编码) | +| groups[].days[].tripDate | LocalDate | 团期行程日 | +| groups[].days[].headcount | Integer | 该组该日用车人数 | +| groups[].days[].memberOrderIds | Array<String> | 成员子订单集合(Long 序列化为字符串) | +| groups[].days[].memberOrderCount | Integer | 当日成员户数 = memberOrderIds.size(),供填人数时对照 | + +#### 请求示例 + +```json +{ + "version": 3, + "remark": "9/13 起换大巴", + "groups": [ + { + "groupId": "1867000000009", + "groupCode": "BUS", + "vehicleType": "35座大巴", + "serviceStartDate": "2026-09-12", + "serviceEndDate": "2026-09-13", + "seats": 19, + "count": 2, + "specialTags": ["child_seat"], + "remark": "含高速费", + "days": [ + { "tripDate": "2026-09-12", "headcount": 9, "memberOrderIds": [2101506167043985410] }, + { "tripDate": "2026-09-13", "headcount": 9, "memberOrderIds": [2101506167043985410] } + ] + } + ] +} +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "requirementId": "1867000000101", + "groupBatchId": "2101506167098511362", + "status": "DRAFT", + "version": 4, + "remark": "9/13 起换大巴", + "confirmedBy": null, + "confirmedAt": null, + "planRefreshState": null, + "planRefreshReplayCount": null, + "blockedStage": null, + "planRefreshStalled": false, + "planRefreshStalledReason": null, + "planRefreshTimeoutAt": null, + "planRefreshReplayExhausted": false, + "groups": [ + { + "groupId": "1867000000009", + "groupCode": "BUS", + "vehicleType": "35座大巴", + "vehicleTypeName": "大巴", + "serviceStartDate": "2026-09-12", + "serviceEndDate": "2026-09-13", + "seats": 19, + "count": 2, + "specialTags": [{ "code": "child_seat", "name": "儿童安全座椅" }], + "remark": "含高速费", + "totalSeatCount": 38, + "maxHeadcount": 9, + "remainingPassengerSeats": 27, + "days": [ + { + "tripDate": "2026-09-12", + "headcount": 9, + "memberOrderIds": ["2101506167043985410"], + "memberOrderCount": 1 + }, + { + "tripDate": "2026-09-13", + "headcount": 9, + "memberOrderIds": ["2101506167043985410"], + "memberOrderCount": 1 + } + ] + } + ] + }, + "success": true +} +``` + +#### 空数据 / 降级响应 + +- 整团没有在团需车户时,`groups: []` 是合法提交,响应 `groups` 为空数组。 +- 存量分组(本批之前建的)回显时 `seats`/`count`/`remark` 为 `null`、`specialTags` 为 `[]`、`totalSeatCount`/`remainingPassengerSeats` 为 `null`。**`null` 表示「这一组还没填过车辆规格」,与填了 0 是两件事(0 通不过 `@Min(1)`)**,前端不要把 null 兜成 0。 +- 车型中文名依赖车队侧车型库:查不到编码或车队不可用时 `vehicleTypeName` 为 `null`,接口仍 200,不阻断页面。 + +```json +{ "code": 200, "message": "成功", "data": { "groups": [] }, "success": true } +``` + +#### 错误响应 + +```json +{ + "code": 809116, + "message": "第 BUS 组座位数不足:19 座 × 1 辆 = 19 座,少于该组最大乘车人数 20 人", + "success": false, + "data": null +} +``` + +```json +{ + "code": 809118, + "message": "第 BUS 组的座位数与车辆数必须同时填写,或同时留空", + "success": false, + "data": null +} +``` + +```json +{ + "code": 809117, + "message": "第 BUS 组特殊诉求标签 wheelchair_lift 不在字典内,请从下拉项中选择", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- **报文里「第 {0} 组」的 {0} 填的是 `groupCode`,不是序号**。809116/809117/809118 三条均如此,报文渲染出来形如「第 BUS 组…」,前端可直接展示、也可据此定位是哪一组填错。 +- **一次只抛一条**:整份提交的多条违规按校验遍历顺序收集,只抛第一条。前端改完一处再保存可能撞上下一条。 +- **座位充足性判据 = `seats × count < 该组最大单日乘车人数`,不扣司机位**(拦截口径),与响应里扣司机位的 `remainingPassengerSeats`(回显口径)刻意差一个司机位。这样「刚好坐满、司机另开一辆」的既有排法不会被一刀拦死。 +- **seats 与 count 同生同死**:只填一个抛 809118;两个都不填 = 存量形态,座位校验整体跳过。 +- **标签校验在事务外先做**:`specialTags` 要调字典服务,校验不过时零写入。字典按 ACTIVE 项放行,运营新增字典项后立即可用(后端未硬编码枚举)。 +- 标签编码会被 trim、丢空白、按首次出现去重保序后落库,前端重复提交同一编码不会报错,但回显只有一条。 +- 整份审核粒度是「整份」,不支持增量补丁;整团不需要用车请走 `POST .../vehicle-requirement/waive`,不要提交空 groups 代替(有需车户时会撞 809103)。 +- 乐观锁:`version` 不一致抛 809102,其报文第二个占位符**不保证是数字**(CAS 落空分支传的是说明文本),前端不要按数字解析。 + +--- + +### 2. 读团期正式用车需求 `GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` + +**VO**: `GroupVehicleRequirementSaveReqVO → GroupVehicleRequirementRespVO` + +#### 使用场景 + +打开团期「查看需求 → 团级正式用车需求」时读取回显,与保存端点共用同一个响应体(`PUT` / `GET` / `withdraw` / `waive` 四端点同体)。本批新增的 8 个字段在这里原样可读。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| groupBatchId | Path | Long | ✅ | - | 团期聚合主键 | + +无请求体。 + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| data | Object | 结构与第 1 节「出参」逐字段相同;该团尚未形成正式用车需求时为 `null` | +| data.groups[].seats / count | Integer | 本批新增;存量分组为 null | +| data.groups[].specialTags | Array<SpecialTagItem> | 本批新增;存量分组为空数组 | +| data.groups[].vehicleTypeName | String | 本批新增;车队不可用或无此编码时为 null | +| data.groups[].totalSeatCount / maxHeadcount / remainingPassengerSeats | Integer | 本批新增;座位或数量缺一时前者与后者为 null | + +#### 请求示例 + +```http +GET /v3/admin/order/group-batch/2101506167098511362/vehicle-requirement HTTP/1.1 +Host: <网关域名> +Authorization: Bearer +``` + +无请求体。 + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "requirementId": "1867000000101", + "groupBatchId": "2101506167098511362", + "status": "CONFIRMED", + "version": 4, + "groups": [ + { + "groupId": "1867000000010", + "groupCode": "SUV", + "vehicleType": "suv", + "vehicleTypeName": "SUV系列", + "serviceStartDate": "2026-09-12", + "serviceEndDate": "2026-09-12", + "seats": 20, + "count": 1, + "specialTags": [], + "remark": null, + "totalSeatCount": 20, + "maxHeadcount": 20, + "remainingPassengerSeats": -1, + "days": [ + { + "tripDate": "2026-09-12", + "headcount": 20, + "memberOrderIds": ["2101506167043985410"], + "memberOrderCount": 1 + } + ] + } + ] + }, + "success": true +} +``` + +上面就是「关键变化」那条口径不一致的真实形态:20 座 × 1 辆载 20 人保存成功,余座 `-1`。 + +#### 空数据 / 降级响应 + +该团尚未形成正式用车需求时 `data` 为 `null`(不是空对象),前端按「尚未创建」渲染: + +```json +{ "code": 200, "message": "成功", "data": null, "success": true } +``` + +#### 错误响应 + +```json +{ + "code": 403, + "message": "无权限访问", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- 判权码 `group-batch:view`,与 `requirement-summary` / `hotel-households` 同码。 +- `status` 不是恒定值,不要按「读到即 CONFIRMED」渲染。 +- 配车刷新观测块七个字段是只读投影,不接受回传、不参与任何提交;判「要不要现在找人」看 `planRefreshStalled`,别看 `planRefreshState`。 +- 存量团期读出来的分组 `seats`/`count` 为 null 是正常形态(绝大多数历史团期都是),不要当异常画红。 + +--- + +### 3. 团期子订单用车需求记录 `GET /v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households` + +**VO**: `GroupVehicleRequirementSaveReqVO → GroupVehicleHouseholdsRespVO` + +#### 使用场景 + +**本批新增端点。** 团期详情「查看需求」Tab 的用车板块下半块:上面是团级正式需求,下面就是本端点——定制师逐户报了什么。形态刻意对齐既有 `.../requirement/hotel-households`,前端可复用用房需求那套「每户一张卡」的展示组件。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| groupBatchId | Path | Long | ✅ | - | 团期聚合主键 | +| kind | Query | String | ❌ | `TRAVEL` / `TRANSFER` | 需求类别过滤;**不传则两类都返**(前端默认不传) | + +无请求体。 + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| groupBatchId | String | 团期 ID(Long 序列化为字符串) | +| departDate | LocalDate | 团期出发日期;未定出发日时为 null | +| householdCount | int | 本列表户数,**按 orderId 去重**(一户同时报了两类只算 1 户) | +| vehicleRowCount | int | 需求行数(一户可能 TRAVEL + TRANSFER 两行),与 householdCount 刻意不等价 | +| countedHouseholdCount | int | 计入 `requirement-summary` 车侧汇总的户数(= 有活跃 TRAVEL 行的户数) | +| households | Array | 逐户明细,按 orderNo 升序 | +| households[].orderId | String | 子订单 ID(Long 序列化为字符串) | +| households[].orderNo | String | 子订单团号 | +| households[].customerName | String | 主联系人姓名 | +| households[].participantCount | int | 出行人数(成人 + 儿童 + 小童 + 婴儿) | +| households[].consultantId | String | 定制师 ID(Long 序列化为字符串);未指派为 null | +| households[].consultantName | String | 定制师姓名;未指派为 null | +| households[].countedInSummary | boolean | 该户是否计入车侧汇总(= 有活跃 TRAVEL 行);只报接送机的户为 false | +| households[].requirements | Array | 该户的用车需求行,0~2 条(TRAVEL / TRANSFER 各至多一条活跃行) | +| requirements[].requirementId | String | 需求行 ID(Long 序列化为字符串) | +| requirements[].kind | String | `TRAVEL` = 行程用车 / `TRANSFER` = 接送机 | +| requirements[].kindName | String | 类别中文名,后端下发,前端不自己映射 | +| requirements[].status | String | PENDING_REVIEW / PENDING / PROCESSING / DONE | +| requirements[].statusName | String | 状态中文名;状态为空或无对应枚举时为 null | +| requirements[].fleet | Array<FleetItem> | 车队明细,定制师所报原貌,不做合并 | +| requirements[].specialTags | Array<SpecialTagItem> | 特殊诉求标签;未填为空列表 | +| requirements[].remark | String | 备注 / 其他诉求,≤500;未填为 null | +| requirements[].serviceDates | Array<LocalDate> | 本需求冻结的服务日期;未冻结时为空列表 | +| requirements[].headcount | Integer | 乘车人数(不含司机) | +| requirements[].totalSeatCount | Integer | 总座位数 = Σ(seats × count);fleet 为空时为 0 | +| requirements[].remainingPassengerSeats | Integer | 余座 = 可载客座位(已按每车扣 1 个司机位)− 乘车人数 | +| requirements[].pickupRequired | Boolean | 是否需要平台接机/接站;**仅 TRANSFER 有意义** | +| requirements[].dropoffRequired | Boolean | 是否需要平台送机/送站;**仅 TRANSFER 有意义** | +| requirements[].returnRemark | String | 打回原因;未被打回过为 null | +| requirements[].returnedAt | LocalDateTime | 打回时间;未被打回过为 null | +| fleet[].vehicleType | String | 车型编码(suv / mpv / bus / sedan);缺失为 null | +| fleet[].vehicleTypeName | String | 车型中文名;车队不可用或无此编码时为 null | +| fleet[].seats / count | Integer | 单车座位数 / 车辆数 | +| specialTags[].code / name | String | 标签编码(字典 `vehicle_special_demand` 的 value)/ 中文名;字典不可用或无此编码时 name 为 null | + +#### 请求示例 + +```http +GET /v3/admin/order/group-batch/2101506167098511362/requirement/vehicle-households?kind=TRANSFER HTTP/1.1 +Host: <网关域名> +Authorization: Bearer +``` + +无请求体。不传 `kind` 时 URL 为 `.../requirement/vehicle-households`,两类都返。 + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "groupBatchId": "2101506167098511362", + "departDate": "2026-09-12", + "householdCount": 1, + "vehicleRowCount": 2, + "countedHouseholdCount": 1, + "households": [ + { + "orderId": "2101506167043985410", + "orderNo": "GT-26-0081", + "customerName": "陈昊", + "participantCount": 4, + "consultantId": "10001", + "consultantName": "李四", + "countedInSummary": true, + "requirements": [ + { + "requirementId": "2101506167043985411", + "kind": "TRAVEL", + "kindName": "行程用车", + "status": "DONE", + "statusName": "已完成", + "fleet": [ + { "vehicleType": "suv", "vehicleTypeName": "SUV系列", "seats": 7, "count": 1 } + ], + "specialTags": [{ "code": "child_seat", "name": "儿童安全座椅" }], + "remark": "含高速费", + "serviceDates": ["2026-09-12", "2026-09-13"], + "headcount": 4, + "totalSeatCount": 7, + "remainingPassengerSeats": 2, + "pickupRequired": null, + "dropoffRequired": null, + "returnRemark": null, + "returnedAt": null + }, + { + "requirementId": "2101506167043985412", + "kind": "TRANSFER", + "kindName": "接送机", + "status": "PENDING", + "statusName": "待处理", + "fleet": [ + { "vehicleType": "mpv", "vehicleTypeName": "商务车", "seats": 7, "count": 1 } + ], + "specialTags": [], + "remark": null, + "serviceDates": ["2026-09-12"], + "headcount": 4, + "totalSeatCount": 7, + "remainingPassengerSeats": 2, + "pickupRequired": true, + "dropoffRequired": false, + "returnRemark": null, + "returnedAt": null + } + ] + } + ] + }, + "success": true +} +``` + +注意这一例里 `householdCount=1` 而 `vehicleRowCount=2`——**表头「共 N 户」必须取 `householdCount`,不能取 `households.length` 之外的行数**。 + +#### 空数据 / 降级响应 + +团里没有任何活跃用车需求(或按 `kind` 过滤后为空)时,三个计数为 0、`households` 为空数组,接口仍 200: + +```json +{ + "code": 200, + "message": "成功", + "data": { + "groupBatchId": "2101506167098511362", + "departDate": "2026-09-12", + "householdCount": 0, + "vehicleRowCount": 0, + "countedHouseholdCount": 0, + "households": [] + }, + "success": true +} +``` + +车型中文名与标签中文名依赖车队侧与字典:不可用时对应 `vehicleTypeName` / `name` 为 `null`,其余字段照常返回,不 500、不阻断页面。 + +#### 错误响应 + +```json +{ + "code": 403, + "message": "无权限访问", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- 判权码与既有 `hotel-households` / `requirement-summary` 一致(`group-batch:view`)。 +- **户数与行数是两个数**:`householdCount` 按 orderId 去重,`vehicleRowCount` 数行。照抄用房侧的写法在「一户两类」时会把表头写错。 +- **被打回的需求行不在本列表内**(打回 = 原地置 REJECTED_* 并失活)。这与用房侧「列出打回户并灰显」**刻意不同**,前端不要按用房那套去找 `countedInSummary=false` 的打回户。 +- `countedInSummary=false` 在本端点的含义是「该户没有活跃 TRAVEL 行」——典型就是只报了接送机的户。车侧汇总只统计行程用车。 +- `pickupRequired` / `dropoffRequired` 仅 TRANSFER 行有意义,TRAVEL 行上为 null。 +- 不传 `kind` = 两类都返,这与提交侧「不传按 TRAVEL」的缺省相反:只读筛选若沿用提交侧缺省,接送机会整类从页面消失且无提示。 + +--- + +### 4. 全团需求汇总 `GET /v3/admin/order/group-batch/{groupBatchId}/requirement-summary` + +**VO**: `GroupVehicleRequirementSaveReqVO → GroupRequirementSummaryRespVO` + +#### 使用场景 + +团期「查看需求」Tab 顶部汇总块。本批只做一件事:新增 `transferSummary`(接送机汇总)。**既有车侧字段(`vehicleRequirementCount`、`vehicleSeatSummary`)口径零改动,仍只统计行程用车。** + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| groupBatchId | Path | Long | ✅ | - | 团期聚合主键 | + +无请求体。 + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| activeOrderCount 等既有字段 | int / Array | 本批不改,口径不变 | +| vehicleRequirementCount | int | 已提交用车需求的子订单数(既有,仍是行程用车口径) | +| vehicleSeatSummary | Array | 大巴座位汇总(既有,仍是行程用车口径) | +| **transferSummary** | Object | **本批新增** 接送机汇总;恒非 null(空团也给空壳) | +| transferSummary.householdCount | int | 有活跃接送机需求的户数(按 orderId 去重) | +| transferSummary.pickupHouseholdCount | int | 其中需要平台接机/接站的户数(pickupRequired=true) | +| transferSummary.dropoffHouseholdCount | int | 其中需要平台送机/送站的户数(dropoffRequired=true) | +| transferSummary.headcount | int | 接送机总人数(各户 headcount 之和;未填人数的户按 0 计) | +| transferSummary.vehicleSeatSummary | Array<TransferSeatItem> | 按车型聚合的接送机座位/台数 | +| transferSummary.serviceDates | Array<LocalDate> | 接送机涉及的服务日期去重集合(升序);未冻结服务日的需求不贡献日期 | +| TransferSeatItem.vehicleType | String | 车型大类编码(suv / mpv / bus / sedan;需求里缺失时为「未知」) | +| TransferSeatItem.vehicleTypeName | String | 车型大类中文名;查不到或车队不可用时为 null | +| TransferSeatItem.seats | int | 合计座位数 = Σ(单车座位 × 台数) | +| TransferSeatItem.count | int | 合计车辆台数 | + +#### 请求示例 + +```http +GET /v3/admin/order/group-batch/2101506167098511362/requirement-summary HTTP/1.1 +Host: <网关域名> +Authorization: Bearer +``` + +无请求体。 + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "activeOrderCount": 6, + "vehicleRequirementCount": 5, + "vehicleSeatSummary": [ + { "vehicleType": "bus", "vehicleTypeName": "大巴", "totalSeats": 38, "totalCount": 2 } + ], + "transferSummary": { + "householdCount": 2, + "pickupHouseholdCount": 2, + "dropoffHouseholdCount": 1, + "headcount": 7, + "vehicleSeatSummary": [ + { "vehicleType": "mpv", "vehicleTypeName": "商务车", "seats": 14, "count": 2 } + ], + "serviceDates": ["2026-09-12", "2026-09-16"] + } + }, + "success": true +} +``` + +#### 空数据 / 降级响应 + +团里没有接送机需求(含空团)时 `transferSummary` **仍是对象、不是 null**,计数为 0、数组为空——前端可以无条件 `v-for`,不必做 null 判断: + +```json +{ + "code": 200, + "data": { + "transferSummary": { + "householdCount": 0, + "pickupHouseholdCount": 0, + "dropoffHouseholdCount": 0, + "headcount": 0, + "vehicleSeatSummary": [], + "serviceDates": [] + } + }, + "success": true +} +``` + +#### 错误响应 + +```json +{ + "code": 403, + "message": "无权限访问", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- 判权码 `group-batch:view`(本端点暴露客户特殊需求)。 +- **`transferSummary` 与既有车侧字段是两套数**:既有 `vehicleSeatSummary` 只含行程用车,接送机的座位/台数只在 `transferSummary.vehicleSeatSummary` 里。两者不要相加当「全团用车」展示,除非页面明确要合计。 +- 🔴 **两组同域字段名不一致,必须写两套映射**:接送机侧与行程车侧都是「按车型聚合的座位/台数」,字段含义完全相同,但**名字不同**(按工单接口表刻意为之,本批不改)。照既有字段名去取新对象会拿到 `undefined`: + +| 含义 | 行程车侧 `vehicleSeatSummary[]`(既有 `VehicleSeatItem`) | 接送机侧 `transferSummary.vehicleSeatSummary[]`(新增 `TransferSeatItem`) | +|------|------|------| +| 车型编码 | `vehicleType` | `vehicleType`(同名) | +| 车型中文名 | `vehicleTypeName` | `vehicleTypeName`(同名) | +| 合计座位数 | `totalSeats` | **`seats`** | +| 合计车辆台数 | `totalCount` | **`count`** | + + 前端不要复用同一个渲染/取数函数,需要两套映射。 +- 车型编码在需求里缺失时归到 `"未知"` 一条,台数不丢。 +- `headcount` 把未填人数的户按 0 计,所以它可能小于 `householdCount` × 实际人数,只作汇总参考。 + +--- + +### 5. 整体确认需求缺失预检 `GET /v3/admin/order/group-batch/{groupBatchId}/requirement/confirm-check` + +**VO**: `GroupVehicleRequirementSaveReqVO → GroupBatchRequirementCheckRespVO` + +#### 使用场景 + +「查看需求」Tab 进入时、以及点「整体确认」前调用,据 `ready` 置灰按钮、据 `missing` / `vehicleMissing` 展示缺哪几户。本批新增两个接送机相关字段。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| groupBatchId | Path | Long | ✅ | - | 团期聚合主键 | + +无请求体。 + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| ready / missing / vehicleMissing / vehicleWaived 等既有字段 | - | 本批不改 | +| **transferSubmitEnabled** | Boolean | **本批新增** 当前环境是否开放接送机需求提交(配置 `hl.order.requirement.transfer-kind-submit-enabled`) | +| **transferDeclaredWithoutRequirement** | Array | **本批新增** 在大交通声明了接送机、却没有活跃 TRANSFER 用车需求行的户;**提示性,不进 ready、不阻断确认** | +| 该数组项 .orderId | String | 子订单 ID(Long 序列化为字符串) | +| 该数组项 .orderNo | String | 子订单号 | +| 该数组项 .customerName | String | 客户姓名 | +| 该数组项 .consultantId | String | 定制师 adminId(Long 序列化为字符串);未指派为 null | +| 该数组项 .consultantName | String | 定制师姓名快照;未指派为 null | +| 该数组项 .pickupRequired | Boolean | 大交通里声明了接机/接站 | +| 该数组项 .dropoffRequired | Boolean | 大交通里声明了送机/送站 | +| 该数组项 .pickupRemark | String | 已声明批次上的文字备注(多条去重后以「;」连接);无备注为 null | + +#### 请求示例 + +```http +GET /v3/admin/order/group-batch/2101506167098511362/requirement/confirm-check HTTP/1.1 +Host: <网关域名> +Authorization: Bearer +``` + +无请求体。 + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "groupBatchId": "2101506167098511362", + "batchStatus": "RESOURCE_PREPARING", + "batchStatusName": "资源准备中", + "ready": true, + "missing": [], + "checkedResourceTypes": ["HOTEL", "VEHICLE"], + "vehicleWaived": true, + "vehicleMissing": [], + "groupVehicleRequirementId": "1867000000101", + "groupVehicleRequirementStatus": "CONFIRMED", + "groupVehicleRequirementVersion": 4, + "transferSubmitEnabled": false, + "transferDeclaredWithoutRequirement": [ + { + "orderId": "60123456789001", + "orderNo": "HL2606010001", + "customerName": "陈昊", + "consultantId": "10001", + "consultantName": "李四", + "pickupRequired": true, + "dropoffRequired": false, + "pickupRemark": "航班 CA1234,落地 14:20" + } + ] + }, + "success": true +} +``` + +上面这一例同时演示了两条边界:`vehicleWaived=true`(整团免车)**不**清空缺口名单;`transferSubmitEnabled=false` 时名单也照报。 + +#### 空数据 / 降级响应 + +无此类户时 `transferDeclaredWithoutRequirement` 是**空数组、不是 null**(null 与「一条都没有」在前端是两种渲染): + +```json +{ + "code": 200, + "data": { "transferSubmitEnabled": true, "transferDeclaredWithoutRequirement": [] }, + "success": true +} +``` + +#### 错误响应 + +```json +{ + "code": 403, + "message": "无权限访问", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- 🔴 **`transferDeclaredWithoutRequirement` 不跟随 `vehicleWaived` 短路**:整团免车豁免的是**团级行程用车**,与「某户自己声明了要接送机」无关。所以团期标了免车,这个名单照样可能非空——**前端不要据 `vehicleWaived` 隐藏它**。 +- 🔴 **`transferSubmitEnabled=false` 时名单同样照报**:缺口是事实,与能不能立刻补是两件事。此时前端应提示「当前环境未开放接送机需求提交」(定制师提交 `kind=TRANSFER` 会被 809004 拒),但**不要因此隐藏名单**——否则管理员会照着名单去催定制师,定制师撞一堵不会变的墙。 +- 该名单是**提示**不是缺失:不进 `ready`、不阻断整团确认、也不并进 `vehicleMissing`(后者语义严格是「TRANSFER 行已存在但服务日未补录」)。混在一起的代价是一个误填会把整团确认卡死,而运营没有手段标记「确认过了、不用管」。 +- 本字段只在 `confirm-check` 读口上有值;整团确认写口共用同一 VO,那条路径上它恒为**空数组**。 +- 车侧新增的两个预检原因码会出现在 `vehicleMissing[].reason` 里:`GROUP_SPEC_INCOMPLETE`(对应 809118)与 `GROUP_SEATS_INSUFFICIENT`(对应 809116),`detail` 字段与整团确认时抛出的报文逐字相同,可直接展示。 + +--- + +## 四、契约约束与正确调用方式(接口类必写) + +> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。 + +### ✅ 正确 / ❌ 错误 payload 对照(`PUT .../vehicle-requirement` 的分组元素) + +| 场景 | payload 片段 | +|------|---------| +| ✅ 存量分组不填规格(两个都不传) | `{ "groupCode": "BUS", "seats": null, "count": null }` → 座位校验整体跳过 | +| ✅ 规格填全且坐得下 | `{ "groupCode": "BUS", "seats": 19, "count": 2 }`,该组最大单日人数 36 → 38 ≥ 36 通过 | +| ✅ 刚好坐满(含驾驶位) | `{ "groupCode": "SUV", "seats": 20, "count": 1 }`,最大单日人数 20 → 20 < 20 不成立,**通过**,回显 `remainingPassengerSeats: -1` | +| ❌ 只填座位数 | `{ "groupCode": "BUS", "seats": 19, "count": null }` → 809118 | +| ❌ 只填车辆数 | `{ "groupCode": "BUS", "seats": null, "count": 2 }` → 809118 | +| ❌ 座位不足 | `{ "groupCode": "BUS", "seats": 19, "count": 1 }`,最大单日人数 20 → 809116 | +| ❌ 座位数为 0 | `{ "groupCode": "BUS", "seats": 0, "count": 1 }` → 400(`@Min(1)`),不是 809116 | +| ❌ 字典外标签 | `{ "groupCode": "BUS", "specialTags": ["wheelchair_lift"] }`(字典无此项)→ 809117,整份零写入 | + +### 切换状态时的必要动作 + +- 想把某组从「已填规格」改回「未填」,必须把 `seats` 与 `count` **同时显式传 null**,只清一个会撞 809118。 +- 想清空某组特殊诉求,传 `"specialTags": []` 或 `null` 均可(后端归一处理);不要传 `[""]`(空白项会被丢弃,但不要依赖这个)。 +- 编辑存量团期时,把 `GET` 拿到的 `seats` / `count`(可能是 null)**原样回传**即可,后端不会因为它们是 null 而拒绝。 + +--- + +## 五、数据库行为 + +只写外部可观察行为: + +| 前端提交(分组元素) | 再次 `GET` 读到 | +|----------|------------------| +| `seats=19, count=2` | `seats: 19, count: 2, totalSeatCount: 38, remainingPassengerSeats: 38 − 2 − maxHeadcount` | +| `seats=null, count=null` | `seats: null, count: null, totalSeatCount: null, remainingPassengerSeats: null` | +| `specialTags=["child_seat","child_seat"," "]` | `specialTags: [{ "code": "child_seat", "name": "儿童安全座椅" }]`(trim + 丢空白 + 去重保序) | +| `remark="含高速费"` | `remark: "含高速费"` | + +**存量分组的四个新字段没有默认值**:本批之前建的分组行读出来是 `seats: null` / `count: null` / `specialTags: []` / `remark: null`,**不是 0、不是空串**。前端渲染必须区分「未填」与「填了值」。 + +**整份替换语义不变**:保存成功后未出现在本次提交里的分组被移出当前版本,其规格字段随之不可见。 + +--- + +## 六、边界行为 + +- 未登录 → 401(网关拦截)。 +- 无 `group-batch:view` 权限 → 403。 +- 团期不存在 → 对应团期错误码,不 500。 +- 车队服务 / 字典服务降级 → `vehicleTypeName`、`specialTags[].name` 为 `null`,其余字段照常返回,不 500 不阻断页面(**只读端点降级不回落成编码,避免前端把编码当中文展示**)。 +- 老数据兼容 → 存量分组无规格字段 → `seats` / `count` / `remark` 为 null、`specialTags` 为 `[]`,不异常;存量团期打开编辑弹窗后原样保存不会被新校验拒绝。 +- 生产环境:二期(order-v3 / fleet)尚未上线生产,本批路径在生产环境为 404。这是既有事实,不是本批引入的。 + +--- + +## 六.5、枚举 / 数据字典 + +### kind(用车需求类别) + +**所属字段**: `GroupVehicleHouseholdsRespVO.households[].requirements[].kind`、以及 `vehicle-households` 的 query 参数 `kind` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `TRAVEL` | 行程用车 | 团期行程期间的用车需求;计入 `requirement-summary` 既有车侧字段 | +| `TRANSFER` | 接送机 | 接送机/接送站需求;计入 `transferSummary`,不进既有车侧字段 | + +query 参数不传 = 两类都返。 + +### status(子订单用车需求行状态) + +**所属字段**: `GroupVehicleHouseholdsRespVO.households[].requirements[].status` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `PENDING_REVIEW` | 待审核 | 定制师已提交,等团期侧核对 | +| `PENDING` | 待处理 | 已进入处理队列 | +| `PROCESSING` | 处理中 | 车务作业中 | +| `DONE` | 已完成 | 本需求行已闭环 | + +被打回的需求行已失活,不出现在本端点的列表里;`statusName` 在状态为空或无对应枚举时为 `null`。 + +### specialTags(字典 `vehicle_special_demand`) + +**所属字段**: `GroupVehicleRequirementSaveReqVO.groups[].specialTags`(提交,`List` 编码)/ `GroupVehicleRequirementRespVO.groups[].specialTags`(回显,`{code,name}`) | **类型**: `String` 编码 + +取值**由运营在字典里维护、后端按字典实际内容校验,代码里不硬编码枚举**——前端必须从字典下拉取值,不要自己写死一份列表。只放行字典里 ACTIVE 的项,含字典外编码整份拒绝(809117)。回显时 `name` 为 null 表示字典里查不到该编码(不回落成编码)。 + +### vehicleType(车型大类编码) + +**所属字段**: `transferSummary.vehicleSeatSummary[].vehicleType`、`vehicle-households` 的 `fleet[].vehicleType` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `suv` | SUV系列 | 中文名取自车型管理库,后端下发 | +| `mpv` | 商务车 | 同上 | +| `bus` | 大巴 | 同上 | +| `sedan` | 轿车 | 同上 | +| `未知` | 未知 | 需求里车型编码缺失时的归并项(仅汇总类字段出现,台数不丢) | + +注意团级 `GroupVehicleRequirementSaveReqVO.groups[].vehicleType` 是**自由文本/字典值、不设档位枚举**(例:`35座大巴`),与上表的大类编码不是同一个取值空间。 + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| `PUT .../vehicle-requirement` 请求 `groups[]` | groupId / groupCode / vehicleType / serviceStartDate / serviceEndDate / days | 额外接受 `seats`、`count`、`specialTags`、`remark`(均非必填) | +| 响应 `groups[]` | groupId / groupCode / vehicleType / 服务日 / days | 额外返回 `vehicleTypeName`、`seats`、`count`、`specialTags`、`remark`、`totalSeatCount`、`maxHeadcount`、`remainingPassengerSeats` | +| `requirement-summary` | 无接送机信息 | 新增 `transferSummary`(恒非 null),既有车侧字段口径不变 | +| `requirement/confirm-check` | 无接送机信息 | 新增 `transferSubmitEnabled`、`transferDeclaredWithoutRequirement`(空时为 `[]`) | +| `vehicleMissing[].reason` 取值 | 不含规格相关原因 | 新增 `GROUP_SPEC_INCOMPLETE`、`GROUP_SEATS_INSUFFICIENT` | +| 新端点 | 无 | `GET .../requirement/vehicle-households` | + +### 行为级对比 + +| 行为 | 改前 | 改后 | +|------|------|------| +| 提交只填座位数或只填车辆数 | 接受(无此字段) | 809118 拒绝,整份零写入 | +| 提交坐不下的规格 | 接受(无此字段) | 809116 拒绝(判据不扣司机位) | +| 提交字典外特殊诉求标签 | 接受(无此字段) | 809117 拒绝,事务外先校验,零写入 | +| 存量团期原样保存 | 通过 | **仍通过**(四个新字段非必填,null 时校验整体跳过) | +| 整团免车(`vehicleWaived=true`)时的接送机缺口 | 无此名单 | 名单**照报**,不随免车短路 | + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**: 否。四个新请求字段全部非必填,不传即维持原行为;新增响应字段是增量。 +- **前端是否必须同步上线**: 是——新增端点与新增字段需要前端渲染;不改前端时页面仍可用,但团级分组无法填写车辆规格、看不到子订单用车记录与接送机汇总。 +- **前端 workaround 清理点**: + - 若此前在前端自行维护过一份 `vehicle_special_demand` 标签中文映射表,可撤掉,改用后端下发的 `specialTags[].name`。 + - 若此前在前端自行维护过车型编码→中文的映射,可撤掉,改用 `vehicleTypeName`。 + - 若此前把 `remainingPassengerSeats` 之类余座数做过 `Math.max(0, x)` 处理,**必须撤掉**——负值是有效信号。 + - 同理,**不要新增「余座为负则禁止保存」的前端校验**:那会把后端放行的组合在 UI 上锁死(见「关键变化」提交侧那条)。 + +--- + +## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面) + +- **仅影响**: 管理后台「团期详情 → 查看需求」Tab 的用车板块(团级正式用车需求、子订单用车需求记录、全团汇总、整体确认预检)。 +- **零影响**: + - `requirement-summary` 的用房侧全部字段(`dailyRoomBreakdown`、`hotelNeededOrderCount` 等)与 `hotel-households` 端点。 + - `requirement-summary` 既有车侧字段 `vehicleRequirementCount` / `vehicleSeatSummary` 的口径(仍只统计行程用车)。 + - 子订单侧用车需求提交端点的字段与校验(本批不改定制师提交侧)。 + - `POST .../requirement/confirm`(整团确认写口)的判决逻辑:新增的接送机缺口名单不进 `ready`、不阻断确认。 + - `POST .../vehicle-requirement/withdraw` / `waive` / `reopen` 三个写端点的语义(它们与 PUT/GET 共用响应体,因此同样多出 8 个字段,但行为不变)。 + - 历史数据:存量分组不迁移,四个新字段保持 null,下次编辑保存时才可能写入。 + +--- + +## 八、测试环境已验证 + +**网关路由**:本批零网关改动。`/v3/admin/**` 通配已配在 `hl-gateway` 的 `order-service-v3` 路由上,新增的 `.../requirement/vehicle-households` 落在既有通配内,无新增 `/admin/` 前缀需要配路由(故 `gateway_status: not_required`)。 + +**自动化回归**(分支 `feat/8152-group-vehicle-structured`,本批新增/改写的用例类): + +``` +GroupVehicleRequirementSaveTest 保存链路(含四个新字段落库与回显) ✓ +GroupVehicleRequirementValidateTest 809116 / 809117 / 809118 三条校验 ✓ +VehicleSeatCalculatorTest 余座口径(扣司机位、允许为负) ✓ +GroupBatchVehicleHouseholdServiceTest vehicle-households 户数/行数/kind 过滤 ✓ +GroupBatchRequirementSummaryTest transferSummary 聚合与空团空壳 ✓ +GroupVehicleRequirementConfirmCheckTest transferSubmitEnabled / 缺口名单不短路 ✓ +TransferDeclarationSupportTest 大交通声明 → 缺口名单推导 ✓ +RequirementServiceVehicleKindsQueryTest TRAVEL / TRANSFER 分类取数 ✓ +GroupDispatchCoverageCalculatorTest(fleet) 团级规格进配车覆盖度基线 ✓ +RedLineArchTest 架构红线门禁 ✓ +``` + +**数据库变更**:`V20260922_401__order_group_vehicle_group_add_fleet_fields.sql`,四列全部可空无默认值(存量行保持 null,见「五、数据库行为」)。 + +**活体实测**(测试服网关 `https://api.test.1814.love:9443`,2026-09-22 13:0x–13:1x;`hl-order-service-v3` @ `f1986f996`,`hl-fleet-service` @ `c4321f961`)。取一个带真实接送机需求的团期(3 户,其中 pickup 2 户、dropoff 1 户,合计 6 人,车型汇总 suv / 15 座 / 3 辆)逐个请求: + +| 端点 | HTTP | 业务 code | 实测要点 | +|---|---|---|---| +| `GET .../vehicle-requirement` | 200 | 200 | 分组元素 8 个新字段全部出现:`vehicleTypeName` `seats` `count` `specialTags` `remark` `totalSeatCount` `maxHeadcount` `remainingPassengerSeats`。该团期分组属存量数据,除 `maxHeadcount` = 6 外新字段为 null / 空数组——**存量行的 GET 侧兼容已实证** | +| `GET .../requirement/vehicle-households` | 200 | 200 | `householdCount` = 3、`vehicleRowCount` = 3、`countedHouseholdCount` = 0、`households[]` 返回 3 条真实行(含 `kind` / `status` / `fleet` / `serviceDates`) | +| 同端点 `?kind=TRANSFER` | 200 | 200 | 过滤参数被接受,返回同 3 条(均为 TRANSFER),无报错 | +| `GET .../requirement-summary` | 200 | 200 | `transferSummary` 节点出现,6 个子字段全部有值:`householdCount` = 3 / `pickupHouseholdCount` = 2 / `dropoffHouseholdCount` = 1 / `headcount` = 6 / `vehicleSeatSummary[]` = 1 条 / `serviceDates` = `["2026-10-30"]` | +| `GET .../requirement/confirm-check` | 200 | 200 | `transferSubmitEnabled` = true(Boolean)、`transferDeclaredWithoutRequirement` = `[]`(数组) | + +⚠️ **上表最后一行的空数组是阴性对照,不是「该字段恒为空」**:该团期的 3 户**全都有** TRANSFER 需求行,本就不该进缺口名单。字段的类型与位置已固定,按契约对接即可;名单非空时的元素结构见本篇「三、接口详情」第 5 节的字段表。 + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#8152](https://git.1814.love:8443/wx/HL/issues/8152)、[wx/HL#8151](https://git.1814.love:8443/wx/HL/issues/8151)、[wx/HL#8153](https://git.1814.love:8443/wx/HL/issues/8153) +- 同批交接件: `changelogs-v2/2026-09/22_8154_团期管理员只读查看子订单与金额面端点收回-修复-管理后台.md`(团期管理员权限行为变更) +- 既有对位端点(形态参考): `changelogs-v2/2026-09/20_8046_团期用房新增子订单订房记录接口-新增接口-管理后台.md` + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#8152](https://git.1814.love:8443/wx/HL/issues/8152) / [#8151](https://git.1814.love:8443/wx/HL/issues/8151) / [#8153](https://git.1814.love:8443/wx/HL/issues/8153) +- **分支**: `feat/8152-group-vehicle-structured` + +### 联系人 + +- **后端负责人**: @wx diff --git a/changelogs-v2/2026-09/22_8154_团期管理员只读查看子订单与金额面端点收回-修复-管理后台.md b/changelogs-v2/2026-09/22_8154_团期管理员只读查看子订单与金额面端点收回-修复-管理后台.md new file mode 100644 index 00000000..72bd6f4c --- /dev/null +++ b/changelogs-v2/2026-09/22_8154_团期管理员只读查看子订单与金额面端点收回-修复-管理后台.md @@ -0,0 +1,559 @@ +--- +schema: "hl-changelog/v2" +ticket: "8154" +title: "团期管理员可打开团期子订单详情(只读),金额/流水面 13 个端点对该角色收回,写面全域拒绝" +consumer: "admin" +author: "wx(GIT)" +change_type: "修复" +backend_status: "deployed" +gateway_status: "not_required" +frontend_status: "pending" +frontend_owner: "" +frontend_ref: "" +target_release: "" +verified_at: "" +status_note: "本条只改权限判定,不改任何请求/响应字段结构。对 GROUP_BATCH_MANAGER 以外的任何角色(超管/定制师/运营/客服/财务/车务/房务)零行为变化。前端需要做的是:给团期管理员这一角色隐藏/兜底金额面与写操作入口,并处理 581008。gateway_status: not_required —— 零网关改动,涉及端点全部落在既有 /v3/admin/** 通配路由内。" +updated_at: "2026-09-22" +base: "dev-v3" +--- + +# order-v3 + user-service: 团期管理员只读查看团期子订单,金额面端点收回,写面全域拒绝 + +> **存放目录**: 二期(v3) → `changelogs-v2/2026-09/` +> +> **服务**: hl-order-service-v3(权限守卫)、hl-user-service(角色菜单绑定) +> **PR**: 见文末「关联 / 联系人」 +> **Issue**: #8154 +> **日期**: 2026-09-22 +> **影响范围**: 管理后台,**仅** `GROUP_BATCH_MANAGER`(团期管理员)角色登录时的订单详情页与团期详情页子订单操作 + +--- + +## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写) + +**本条只改「谁能调」,不改「调通了返回什么」。所有请求/响应字段结构逐字节不变。** + +对 `GROUP_BATCH_MANAGER` 三件事同时生效: + +1. **放开**:10 个需求核对类只读端点,从 581008 变为正常返回——**但只对团期子订单**(`groupBatchId` 非空)。散客单仍 581008。 +2. **收回**:13 个金额 / 成本 / 流水读端点,对该角色返回 581008(本批之前它们与第 1 组是同一道守卫,一放就全放,所以这是**同批收回**,不是先放后收)。 +3. **拒绝**:订单详情页与团期详情页能触发的**全部写端点**,对该角色返回 581008。 + +🔴 **「端点收回」不等于「金额不可见」**——见「六、边界行为」的已知缺口一节,主详情响应体里仍含 7 个金额字段。前端按该节处理。 + +--- + +## 一、背景(选填) + +团期管理员要核对团期下各子订单报了什么需求,此前点「进入子订单」一律 581008,页面打不开。放开读面时读面里混着两类端点:核对需求要看的,和暴露供应商成本与资金流水的。工单诉求只到前者,后者一旦被看到不可逆,故同一批里把后者单独拆出去收回;同时该角色的「只能看不能修改」必须在写面落地,否则放开读权后它能改同行人、改行程、发起退款、开合同、改保险、改大交通,甚至修改 / 取消 / 终止订单主单。 + +| 维度 | 本批之前 | 本批之后 | +|------|----------|----------| +| 打开团期子订单详情 | 581008 | 正常返回(仅团期子订单) | +| 打开散客单详情 | 581008 | 581008(不变) | +| 财务 / 发票 / 退款 / 流水等 13 个端点 | 581008 | 581008(不变,但改由独立守卫判定) | +| 订单域写端点 | 该角色调不到(读面进不去) | 明确 581008 | +| 前端路由 `/order-v2/detail/:id` | 该角色未绑菜单,点「进入」命中兜底路由 404 | 已绑菜单,路由可注册 | + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 需求核对类只读端点(10 个) | GET | `/v3/admin/order/{id}` 等 | 权限放开 | 仅 GROUP_BATCH_MANAGER + 仅团期子订单 | +| 2 | 金额 / 成本 / 流水读端点(13 个) | GET | `/v3/admin/order/{id}/finance` 等 | 权限收回 | 对 GROUP_BATCH_MANAGER 返 581008 | +| 3 | 订单域写端点(14 个控制器 + 2 个团期动作) | POST / PUT / DELETE | `/v3/admin/order/**` 等 | 权限拒绝 | 对 GROUP_BATCH_MANAGER 返 581008 | + +--- + +## 三、接口详情 + +### 1. 需求核对类只读端点(10 个,权限放开) `GET /v3/admin/order/{id}` + +**VO**: `OrderDetailReqVO → OrderDetailRespVO` + +#### 使用场景 + +团期管理员在「团期详情 → 子订单列表」点「进入」,打开子订单详情页核对该户报了什么需求。本组端点即该页面各 Tab 的取数入口。 + +**本组完整清单**(路径逐一列全,前端按此判断哪些请求现在能发): + +| # | 方法 | 路径 | 页面位置 | +|---|------|------|----------| +| 1 | GET | `/v3/admin/order/{id}` | 订单详情主体(9 Tab 聚合入口) | +| 2 | GET | `/v3/admin/order/{id}/itinerary` | 行程安排 Tab | +| 3 | GET | `/v3/admin/order/{id}/confirm-checklist` | 确认清单 | +| 4 | GET | `/v3/admin/order/{id}/service-standard` | 服务标准 Tab | +| 5 | GET | `/v3/admin/order/{id}/status-log` | 状态记录时间线 Tab | +| 6 | GET | `/v3/admin/order/{id}/print-itinerary` | 打印行程单(司机 Driver Copy) | +| 7 | GET | `/v3/admin/order/{id}/push-records` | 推送记录 Tab | +| 8 | GET | `/v3/admin/order/{id}/contract-insurance` | 合同保险 Tab | +| 9 | GET | `/v3/admin/order/{orderId}/itinerary-document` | 电子行程单(对客视角) | +| 10 | GET | `/v3/admin/order/meal-info/list` | 用餐信息列表 | + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| id / orderId | Path | Long | ✅ | 前 9 个端点 | 子订单 ID;**必须是团期子订单**,散客单 581008 | +| orderId | Query | Long | ❌ | 第 10 个端点(`meal-info/list`) | 传了才逐单判权;不传按查询条件本身的口径取数 | +| documentType | Query | String | ✅ | 第 9 个端点 | `CUSTOMER` / `CUSTOMER_PRINT` / `CUSTOMER_QUOTE` | +| Authorization | Header | String | ✅ | Bearer token | 角色 key 由网关透传,前端不传角色 | + +**请求参数与请求体结构本批零改动**,上表只列与判权相关的部分。 + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| data | Object | **响应结构本批零改动**,与该角色之外的角色拿到的完全相同 | +| data.main | Object | 订单主信息;⚠️ 含 7 个金额字段,见「六、边界行为」已知缺口 | +| data.tags | Array | 订单标签 | +| data.overview | Object | 概览(含 hotelRemark / vehicleRemark 等) | + +其余 9 个端点的响应结构同样零改动,此处不重复列出——本条 changelog 不引入任何新字段。 + +#### 请求示例 + +```http +GET /v3/admin/order/2101506167043985410 HTTP/1.1 +Host: <网关域名> +Authorization: Bearer <团期管理员的 token> +``` + +无请求体。 + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "main": { + "orderId": "2101506167043985410", + "orderNo": "GT-26-0081", + "groupBatchId": "2101506167098511362", + "orderStatus": "CONFIRMED" + }, + "tags": [], + "overview": {} + }, + "success": true +} +``` + +(示例省略了与本条无关的字段,实际响应结构与本批之前逐字段相同。) + +#### 空数据 / 降级响应 + +部分 Tab 在无数据时 `data` 为 `null`(如服务标准快照缺失、无退款),这是**既有行为,本批不改**: + +```json +{ "code": 200, "message": "成功", "data": null, "success": true } +``` + +#### 错误响应 + +散客单(`groupBatchId` 为 NULL)对该角色仍然拒绝: + +```json +{ + "code": 581008, + "message": "无权查看此订单", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- **放行条件是两个而不是一个**:角色为 `GROUP_BATCH_MANAGER` **且** `order.groupBatchId != null`。缺任一条 → 581008。 +- 🔴 **放行范围是「全站团期子订单」,不是「他负责的那个团」**:按管理员归属隔离要走 `order_group_batch.batch_manager_id`,该列当前全站为 NULL、显式不启用,做不到隔离。前端不要据此假设「他只能看到自己的团」。 +- 判权发生在**后端**,与前端菜单权限码无关:即使前端藏了入口,直接拼 URL 也按上面两条判。 +- 房务管理员 / 房务组长在本组端点上仍是 581045(`房务角色无权查看订单详情,房务仅可配房`),与本批无关。 +- 其余角色(超管 / 定制师 / 运营 / 客服 / 财务 / 车务)在本组端点上**零行为变化**。 + +--- + +### 2. 金额 / 成本 / 流水读端点(13 个,权限收回) `GET /v3/admin/order/{id}/finance` + +**VO**: `OrderFinanceReqVO → FinanceVO` + +#### 使用场景 + +订单详情页的财务 / 发票 / 退款等 Tab,以及预付、优惠加价、收款流水、手工收款等金额面取数。**团期管理员调用本组任一端点一律 581008**——前端应对该角色隐藏这些 Tab 与按钮,而不是让它点开后吃一个错误弹窗。 + +**本组完整清单(13 个 GET 端点,逐一列全)**: + +| # | 路径 | 内容 | +|---|------|------| +| 1 | `/v3/admin/order/{id}/finance` | 财务 Tab | +| 2 | `/v3/admin/order/{id}/invoices` | 发票 Tab | +| 3 | `/v3/admin/order/{id}/refund` | 退款明细 Tab | +| 4 | `/v3/admin/order/{id}/cancel-preview` | 取消订单预览(金额 + 政策) | +| 5 | `/v3/admin/order/{id}/sign-voucher` | 签单凭证(对供应商核成本) | +| 6 | `/v3/admin/order/{orderId}/advances` | 预付列表 | +| 7 | `/v3/admin/order/{orderId}/advance/payee-candidates` | 预付收款方候选 | +| 8 | `/v3/admin/order/{orderId}/discount-surcharge/list` | 优惠 / 加价明细 | +| 9 | `/v3/admin/order/{orderId}/payment/list` | 收款流水 | +| 10 | `/v3/admin/order/{orderId}/payment/manual-receipt` | 手工收款记录 | +| 11 | `/v3/admin/order/{orderId}/payment/manual-receipt/options` | 手工收款选项 | +| 12 | `/v3/admin/order/invoice/{id}` | 发票详情 | +| 13 | `/v3/admin/order/invoice/{id}/push-logs` | 发票推送日志 | + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| id / orderId | Path | Long | ✅ | - | 订单 ID(第 12、13 项为发票 ID) | +| Authorization | Header | String | ✅ | Bearer token | 角色 key 由网关透传 | + +入参结构本批零改动。 + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| data | Object | **结构零改动**;对 GROUP_BATCH_MANAGER 永远拿不到(先抛 581008) | + +#### 请求示例 + +```http +GET /v3/admin/order/2101506167043985410/finance HTTP/1.1 +Host: <网关域名> +Authorization: Bearer <团期管理员的 token> +``` + +无请求体。 + +#### 响应示例 + +对**非**团期管理员角色(超管 / 定制师 / 客服 / 财务 / 车务),响应与本批之前完全一致: + +```json +{ + "code": 200, + "message": "成功", + "data": { "payments": [] }, + "success": true +} +``` + +#### 空数据 / 降级响应 + +无数据时 `data` 为 `null` 或空数组,属既有行为,本批不改: + +```json +{ "code": 200, "message": "成功", "data": null, "success": true } +``` + +#### 错误响应 + +团期管理员调用本组任一端点: + +```json +{ + "code": 581008, + "message": "无权查看此订单", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- 本组与第 1 组**使用同一个错误码 581008**,前端无法靠错误码区分「这个端点该角色永远调不了」与「这单不是团期子订单」。区分办法是看请求的是哪个端点:本组 13 个对该角色恒 581008。 +- **收回是按端点清单做的,不是按响应体自动判的**:新增金额面端点不会自动纳入。若前端发现某个含金额的端点对该角色返回了 200,那是缺口,请报工单,不要当成允许。 +- **对其他角色零行为变化**:本组端点改挂的守卫与本批之前的读面守卫在开关上逐字节相同(车务放行、房务 581045、其余须本单定制师),只把新放开的那一个角色收回去。 +- 第 9~11 项在控制器与 Service 各判一次(刻意的双层防御),行为一致,不会出现「一层放一层拒」的中间态。 + +--- + +### 3. 订单域写端点(权限拒绝) `POST /v3/admin/order/group-batch/sub-order/{orderId}/withdraw` + +**VO**: `SubOrderWithdrawReqVO → SubOrderWithdrawRespVO` + +#### 使用场景 + +订单详情页与团期详情页上一切「改」的动作。团期管理员是**只读**角色,本组一律 581008。前端应对该角色隐藏全部写入口(含详情页内的编辑按钮、Tab 内的新增/删除、团期侧的撤出/转入)。 + +**纳管范围(按控制器,逐一列全)**: + +| 控制器 | 覆盖的写面 | +|--------|-----------| +| `OrderController` | 创建 / 修改 / 行前取消 / 终止预览 / 终止 / 状态流转 / 确认行程(7 个写端点) | +| `TravelerAdminController` | 出行人增删改 | +| `TransportPlanAdminController` | 大交通方案增删改 | +| `ItineraryAdminController` / `ItineraryEditAdminController` | 行程与行程编辑 | +| `AdminRefundController` | 退款发起与处理 | +| `AdminContractController` | 合同 | +| `AdminInsuranceController` | 保险 | +| `CollabAdminController` | 协作 | +| `AdminWorkOrderController` | 工单 | +| `AdminTeamReportController` | 团报 | +| `OrderAdvanceController` | 预付 | +| `InvoiceAdminController` / `AdminInvoiceController` | 发票 | +| `GroupBatchActionController` | 子订单撤出(`withdraw`)、转入(`transfer-in`)两个团期动作 | + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| orderId | Path | Long | ✅ | - | 子订单 ID | +| 请求体 | Body | Object | 视端点而定 | - | **结构本批零改动** | +| Authorization | Header | String | ✅ | Bearer token | 角色 key 由网关透传 | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| data | Object | **结构零改动**;对 GROUP_BATCH_MANAGER 永远拿不到(先抛 581008) | + +#### 请求示例 + +```http +POST /v3/admin/order/group-batch/sub-order/2101506167043985410/withdraw HTTP/1.1 +Host: <网关域名> +Authorization: Bearer <团期管理员的 token> +Content-Type: application/json +``` + +```json +{ "reason": "客户取消" } +``` + +#### 空数据 / 降级响应 + +本组端点不返回列表,无空数据形态;对有权角色的成功响应与本批之前一致: + +```json +{ "code": 200, "message": "成功", "data": true, "success": true } +``` + +#### 错误响应 + +```json +{ + "code": 581008, + "message": "无权查看此订单", + "success": false, + "data": null +} +``` + +⚠️ **报文文案是「无权查看此订单」,出现在写操作上会读着别扭**——这是刻意复用读侧错误码(前端对 581008 已有一套处理),不是挂错。前端在写入口上可自行换一句更贴切的提示文案,但判据仍是 581008。 + +#### 业务边界 + +- 拒绝**只针对 `GROUP_BATCH_MANAGER` 这一个角色**,与订单是不是团期子订单无关(写面不做 `groupBatchId` 分叉)。 +- **对其他任何角色零行为变化**:这些写端点此前对客服 / 运营 / 财务等角色没有归属校验,本批**保持原样**,不要把本条读成「订单写面已做归属收口」。 +- MQ 回放 / 定时任务 / 内部 Feign / 单测等非请求上下文无角色,放行,与既有守卫一致。 + +--- + +## 四、契约约束与正确调用方式(接口类必写) + +> 本节只写**后端接受/拒绝请求的规则**,不写 UI 渲染建议。 + +### ✅ 放行 / ❌ 拒绝对照(调用方角色 = GROUP_BATCH_MANAGER) + +| 场景 | 结果 | +|------|------| +| ✅ `GET /v3/admin/order/{id}`,该单 `groupBatchId` 非空 | 200,正常返回 | +| ✅ `GET /v3/admin/order/{id}/itinerary`,团期子订单 | 200 | +| ✅ `GET /v3/admin/order/meal-info/list?orderId=<团期子订单>` | 200 | +| ❌ `GET /v3/admin/order/{id}`,该单 `groupBatchId` 为 NULL(散客单) | 581008 | +| ❌ `GET /v3/admin/order/{id}/finance`(哪怕是团期子订单) | 581008 | +| ❌ `GET /v3/admin/order/{orderId}/payment/list` | 581008 | +| ❌ `PUT /v3/admin/order/{id}`(修改订单) | 581008 | +| ❌ `POST /v3/admin/order/group-batch/sub-order/{orderId}/withdraw` | 581008 | + +### 切换状态时的必要动作 + +- 前端不需要、也不应该在请求里传角色:角色 key 由网关从 JWT 透传,后端只认它。 +- 判断「当前用户能不能看金额面」**不要靠试调**:按当前登录角色是否为团期管理员在前端直接分支,避免每个 Tab 打开时先吃一个 581008。 +- 团期管理员登录后需**重新登录或刷新页面**才能拿到新注册的 `/order-v2/detail/:id` 路由(前端路由表在登录时一次性生成)。 + +--- + +## 五、数据库行为 + +本条不改任何业务数据结构。唯一的数据变更是**角色菜单绑定**(hl-user-service): + +| 变更 | 外部可观察行为 | +|------|----------------| +| 给 `GROUP_BATCH_MANAGER` 绑定「订单详情」菜单 `/order-v2/detail/:id` | `GET /admin/menu/my` 对该角色多返回这条菜单,前端动态路由才能注册该路径 | + +- 该角色**只绑「订单详情」,不绑「订单列表」**:绑列表等于给全站订单的浏览入口,超出诉求。父目录「订单管理v2」该角色此前已持有,路由父链完整。 +- 菜单不走 Redis 缓存,但**前端路由表在登录时一次性生成**:已登录的会话需要重新登录或刷新页面才看到新路由。 +- 在此之前,该角色从未注册过 `/order-v2/detail/:id` 路由,团期详情页点子订单「进入」会命中前端兜底路由 404,请求压根发不到后端——所以本批之前看到的 404 与后端 581008 是两回事。 + +--- + +## 六、边界行为 + +- 未登录 → 401(网关拦截)。 +- 团期管理员访问散客单的任一读端点 → 581008。 +- 团期管理员访问 13 个金额面端点 → 581008。 +- 团期管理员访问任一订单域写端点 → 581008。 +- 房务管理员 / 房务组长访问订单详情读面 → 581045(既有行为,不变)。 +- 订单不存在 → 订单不存在错误码,不 500。 + +### 🔴 已知缺口:端点收回 ≠ 金额不可见 + +本批**只收端点、不改响应体**(响应脱敏是独立设计,不在本单)。所以团期管理员打开子订单详情时,**仍会在响应里拿到金额**: + +| 位置 | 仍可见的内容 | +|------|--------------| +| `GET /v3/admin/order/{id}` 的 `main` | `totalAmount`、`payableAmount`、`paidAmount`、`refundAmount`、`balanceAmount`、`depositAmount`、`singleRoomSurcharge` 共 7 个金额字段 | +| `GET /v3/admin/order/{id}/contract-insurance` | 保费 | +| `GET /v3/admin/order/{id}/itinerary` | 协议价与结算价 | + +主详情之所以不一并收回,是因为它是页面入口,收了等于工单诉求落空。 + +**前端据此决定渲染**:若产品口径要求该角色看不到金额,需要在前端按角色隐藏上述字段的展示;后端此版不做脱敏,字段会照常下发。 + +--- + +## 六.5、枚举 / 数据字典 + +### role_key(与本条判权相关的后台角色) + +**所属字段**: 不在任何请求/响应体中——由网关从 JWT 的 `role_key` 经 `X-Admin-Role` 透传给后端 | **类型**: `String` + +| 值 | 中文 | 在本条中的行为 | +|----|------|----------------| +| `GROUP_BATCH_MANAGER` | 团期管理员 | 本条唯一行为变化的角色:团期子订单读面放行,金额面 13 端点 581008,写面全域 581008 | +| `ADMIN` / `SUPER_ADMIN` | 管理员 / 超级管理员 | 恒放行,零变化 | +| `VEHICLE_MANAGER` | 车务管理员 | 读面与金额面均放行(派车需看签单与预付),零变化 | +| `ROOM_MANAGER` | 房务管理员 | 订单详情读面 581045,零变化 | +| `house_keeper_lead` | 房务组长 | 同房务管理员,零变化 | +| 其余(定制师 / 运营 / 客服 / 财务等) | - | 须为本单定制师,否则 581008,零变化 | + +### 本条涉及的错误码 + +**所属字段**: `Result.code` | **类型**: `Integer` + +| 值 | 报文 | 触发条件 | +|----|------|----------| +| `581008` | `无权查看此订单` | 团期管理员访问散客单 / 金额面端点 / 任一写端点;或其他角色非本单定制师 | +| `581045` | `房务角色无权查看订单详情,房务仅可配房` | 房务管理员 / 房务组长访问订单详情读面(既有,不变) | + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| 全部涉及端点的请求体 | - | **逐字段不变** | +| 全部涉及端点的响应体 | - | **逐字段不变**(含主详情的 7 个金额字段,见已知缺口) | + +本条零字段变更,变的只有判权结果。 + +### 行为级对比(调用方角色 = GROUP_BATCH_MANAGER) + +| 行为 | 改前 | 改后 | +|------|------|------| +| 点团期子订单「进入」 | 前端路由未注册 → 404(请求发不出去) | 路由可注册,详情页可打开 | +| `GET /v3/admin/order/{团期子订单}` | 581008 | 200 | +| `GET /v3/admin/order/{散客单}` | 581008 | 581008 | +| 行程 / 确认清单 / 服务标准 / 状态日志 / 打印行程单 / 推送记录 / 合同保险 / 行程文档 / 用餐信息 | 581008 | 200(仅团期子订单) | +| 财务 / 发票 / 退款 / 取消预览 / 签单 / 预付 / 优惠加价 / 收款流水 / 手工收款(13 端点) | 581008 | 581008 | +| 改同行人 / 改行程 / 发起退款 / 开合同 / 改保险 / 改大交通 / 改订单主单 | 读面进不去,实际调不到 | 明确 581008 | +| 子订单撤出 / 转入 | 同上 | 明确 581008 | + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**: 否。零字段变更;对 `GROUP_BATCH_MANAGER` 之外的任何角色零行为变化。 +- **前端是否必须同步上线**: 是。需要按角色控制入口——否则团期管理员打开子订单详情后,金额面 Tab 与写按钮仍在页面上,点一次吃一个 581008。 +- **前端 workaround 清理点**: + - 若此前为「团期管理员点子订单必 404」做过前端兜底提示(例如直接禁用「进入」按钮、或点击后提示无权限),**可以撤掉**——该角色现在能正常打开团期子订单详情。 + - 若此前把团期管理员当成「订单域完全无权」的角色做过整块屏蔽,需要改成**分面控制**:读面开、金额面关、写面关。 + - 已登录的团期管理员账号需要重新登录或刷新页面才能拿到新注册的详情路由,前端如有路由缓存需一并处理。 + +--- + +## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面) + +- **仅影响**: 管理后台 `GROUP_BATCH_MANAGER`(团期管理员)角色的订单域行为。 +- **零影响**: + - 其他全部后台角色(超管 / 管理员 / 定制师 / 运营 / 客服 / 财务 / 车务 / 房务 / 房务组长)在上述任一端点上的权限与响应。 + - 所有请求参数与响应字段结构(零字段变更)。 + - 小程序端 `/v3/mp/**` 全部接口。 + - 内部 Feign `/v3/internal/**` 与 MQ 回放 / 定时任务链路(非请求上下文无角色,放行,与既有守卫一致)。 + - 订单写端点对其余角色的归属校验现状(存量缺口保持原样,本批不治理)。 + - 房务配房链路(走 in-process 聚合器,不经本批端点)。 + - 团期需求域的用车 / 用房需求接口(那批改动见同批另一份交接件)。 + +--- + +## 八、测试环境已验证 + +**网关路由**:本条零网关改动。涉及端点全部落在 `hl-gateway` 已配置的 `/v3/admin/**` 通配路由内,无新增 `/admin/` 前缀(故 `gateway_status: not_required`)。 + +**架构门禁**(分支 `fix/8154-group-batch-manager-order-view`): + +``` +GroupBatchManagerFinanceReadGuardArchTest 13 个金额端点(15 个方法)必须且只能挂财务守卫 ✓ +GroupBatchManagerWriteGuardArchTest 14 个写面控制器 + 2 个团期动作方法全部纳管 ✓ +RoleClaimFailOpenInventoryTest 角色缺失放行清册未被意外改动 ✓ +RedLineArchTest 架构红线门禁 ✓ +``` + +两条金额面门禁是成对的:一条要求财务端点**必须**调财务守卫,另一条要求它们**不得**调放行团期管理员的宽松守卫——只有第一条时,两条守卫都写上仍会绿,而宽松那条在顺序靠前时会先放行。 + +**数据库变更**:`V20260922_154__grant_order_detail_menu_to_group_batch_manager.sql`(hl-user-service),`INSERT IGNORE` + 唯一键,重复执行零新增行;角色或菜单任一不存在时匹配 0 行、迁移仍成功。 + +**生产环境覆盖边界**:二期(order-v3 / fleet)尚未上线生产,本条涉及的 `/v3/admin/**` 路径在生产环境为 404;生产库 `sys_menu` 是否存在 `path = '/order-v2/detail/:id'` 这一行未查证,若不存在则该菜单迁移在生产上按设计安全跳过。这是既有事实,不是本批引入的。 + +**活体实测**(测试服网关 `https://api.test.1814.love:9443`,2026-09-22 13:0x–13:1x;`hl-order-service-v3` @ `f1986f996`,`hl-user-service` @ `c4321f961`)。用一个 `GROUP_BATCH_MANAGER` 角色账号,对一个团期子订单与一个散客单逐个请求。左列是本批改动**前**在同一环境实测的读数,右列是改动后: + +| 端点 | 改动前 | 改动后 | 结论 | +|---|---|---|---| +| `GET /v3/admin/order/{团期子订单 id}` | 581008 | 200 | 放行 | +| `GET .../{id}/itinerary` | 581008 | 200 | 放行 | +| `GET .../{id}/status-log` | 581008 | 200 | 放行 | +| `GET .../{id}/finance` | 581008 | 581008 | 不变 | +| `GET .../{id}/invoices` | 581008 | 581008 | 不变 | +| `GET .../{id}/refund` | 581008 | 581008 | 不变 | +| `GET .../{id}/sign-voucher` | 581008 | 581008 | 不变 | +| `GET .../{id}/contract-insurance` | 581008 | 581008 | 不变 | +| `GET /v3/admin/order/{散客单 id}` | 581008 | 581008 | **阴性对照**:放行范围未扩大到散客单 | +| `PUT /v3/admin/order/{团期子订单 id}` | **放行,返 `data: true`** | **581008** | **写面缺口已堵** | + +(表中的 581008 指响应体里的业务 `code`,HTTP 状态码按本仓约定一律为 200。) + +最后一行是本批最有分辨力的一条读数:**改动前该角色读订单被拒、写订单却畅通**——读面有守卫挡着,写面当时没有任何团期管理员判权。它不是为放开读权而配套加的保险,它本身就是一个既有越权,本批一并收口。 + +**user-service 迁移已在测试环境生效**(启动日志原文): + +``` +Migrating schema `hl_user_service` to version "20260922.154 - grant order detail menu to group batch manager" +Successfully applied 1 migration to schema `hl_user_service`, now at version v20260922.154 +``` + +第二个实例随后启动时读到 `Current version ... 20260922.154` / `Schema is up to date`,两实例一致。 + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#8154](https://git.1814.love:8443/wx/HL/issues/8154) +- 同批交接件: `changelogs-v2/2026-09/22_8152_团期用车需求补车辆规格与接送机汇总-修改接口-管理后台.md`(团期需求域字段与端点变更) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#8154](https://git.1814.love:8443/wx/HL/issues/8154) +- **分支**: `fix/8154-group-batch-manager-order-view` + +### 联系人 + +- **后端负责人**: @wx