--- schema: "hl-changelog/v2" ticket: "8220" title: "团期正式行程用车需求自动汇总草稿" consumer: "admin" author: "jw(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "ee98b96654fd250159ee4a3b0067e5f1c88d3b75" target_release: "v2.1" verified_at: "2026-09-23" status_note: "后端已部署 TEST 并经网关实测;待 mmg 在编辑弹窗接入「自动汇总」+ 乘车户全选 + 人数随所选户汇总;前端已交付:编辑弹窗「自动汇总」灌草稿+四诊断展示+大类收敛(suv→suv2)+version 用草稿值+非 DRAFT 禁保存;spec 41 例全绿,checkpoint 13 项过" updated_at: "2026-09-23" base: "dev-v3" --- # 团期正式行程用车需求自动汇总草稿(#8220) > **服务**: hl-order-service-v3(经网关调用,无需关心服务端口) > **PR**: #8273 > **Issue**: #8220 > **日期**: 2026-09-23 > **影响范围**: 管理后台团期详情「用车」Tab 的正式行程用车需求编辑弹窗 --- ## ⚠️ 关键变化 新增只读端点,按各子订单已提交的行程用车(TRAVEL)需求,自动汇总出团级正式用车需求草稿。草稿形状与保存端点 `PUT .../vehicle-requirement` 的请求体**完全一致**,前端可以直接灌进编辑弹窗、原样保存。 **四条会直接影响你怎么写代码的点,按严重度排**: 1. 🔴 **草稿之外的四个诊断字段必须展示,不能只看 `draft`**。`droppedFleetItems` 里是**没进草稿的车型需求**(一户报了多个车型时只保留主车型),不提示的话,管理员一保存,这些需求就从团级正式需求里消失了。另外三个字段(`staleHeadcountOrders` / `paddedOrderDays` / `violations`)见业务边界。 2. 🔴 **车型回显是归一后的大类 key(`suv` / `bus` / `mpv` / `sedan`)**,与 #8221 交接件里提过的是同一个问题:车型下拉的 value 是 fleet typeKey `suv2`,拿 `suv` 逐字去比会判成「非字典值,请重选」,并被前端校验拦住保存。**判「是否字典值」要按归一后的大类比**,否则所有 SUV 组汇总出来都会被弹窗拦下。 3. **有户还没提交行程用车需求时,本端点直接返回业务错误 `809121`**,报文逐户列出「订单号(orderId):原因」。这时不要尝试拼一份草稿,应提示管理员去催这些户。 4. **成功码是 `code: 200`**;业务失败也返回 HTTP 200,**一律按 `body.code` 判**。 --- ## 一、背景(选填) wx 2026-09-23 团期详情页「用车」Tab 反馈:「正式的行程用车需求要根据子订单的行程用车需求自动汇总 自动填写」,以及「得有个全选的按钮,根据选的乘车户自动把人数算出来」。此前编辑弹窗不拉取任何子订单需求数据,日期预填的是团期自己的出发/结束日,其余字段全靠手填。汇总规则由管理者于 2026-09-23 定案(#8220 评论)。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 自动汇总正式用车需求草稿 | GET | `/v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/aggregate-draft` | 新增接口 | 只读,返回可原样保存的草稿与汇总诊断 | --- ## 三、接口详情 ### 1. 自动汇总正式用车需求草稿 `GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/aggregate-draft` **VO**: `GroupVehicleAggregateDraftRespVO` #### 使用场景 管理员在编辑弹窗里点「自动汇总」时调用。拿到 `data.draft` 后填进弹窗,同时展示四个诊断字段;管理员确认后,把 `draft` 原样(或编辑后)交给 `PUT /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` 保存。 弹窗里如果已经有编辑内容,点「自动汇总」前请二次确认「这将覆盖当前编辑内容」。本端点不管现在有没有正式需求,都返回完整汇总,要不要覆盖由前端交互决定。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | `groupBatchId` | Path | Long | ✅ | — | 团期 ID(`group_batch_id`),🔴 不是产品班期 ID | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | `groupBatchId` | String(雪花 ID) | 团期 ID | | `currentStatus` | String / null | 当前正式需求状态;还没形成时为 null。**只有 null 或 `DRAFT` 时才能保存**(其余状态保存会报 809101 / 809115),前端据此决定「保存」按钮是否可用 | | `draft` | Object | 汇总草稿,与保存请求体同形,**恒非 null** | | `draft.version` | Integer / null | 当前正式需求的乐观锁版本;未形成时为 null。保存时原样带上 | | `draft.remark` | String / null | 整份备注,汇总时恒为 null | | `draft.groups[]` | Array | 乘车分组,没有可汇总内容时为空数组 | | `draft.groups[].groupId` | Long / null | 恒为 null(汇总产物一律是新组) | | `draft.groups[].groupCode` | String | 车型大写,同车型按连续日期段拆组时第二段起加序号:`BUS`、`BUS2`… | | `draft.groups[].vehicleType` | String | 归一后的车型大类 key:`bus` / `suv` / `mpv` / `sedan` | | `draft.groups[].serviceStartDate` | String(`yyyy-MM-dd`) | 本组首日 | | `draft.groups[].serviceEndDate` | String(`yyyy-MM-dd`) | 本组末日 | | `draft.groups[].seats` | Integer / null | 组内各户保留车型项里最大的单车座位数;各户都没填座位时,与 `count` 一起为 null | | `draft.groups[].count` | Integer / null | `ceil(本组最忙那天的人数 / (seats − 1))`,已扣除司机座 | | `draft.groups[].specialTags` | String[] | 组内各户特殊诉求编码的并集(去重、保持顺序) | | `draft.groups[].remark` | String / null | 逐户「订单号: 定制师备注」和「订单号 另报 suv 7座×1」拼接而成,超过 500 字截断。**仅供人眼留底** | | `draft.groups[].days[]` | Array | 逐日明细,正好铺满本组首日到末日 | | `draft.groups[].days[].tripDate` | String(`yyyy-MM-dd`) | 日期 | | `draft.groups[].days[].headcount` | Integer | 当天在组各户的**实时**人数之和 | | `draft.groups[].days[].memberOrderIds` | String[](雪花 ID) | 当天在组的子订单 ID。🔴 19 位雪花 ID,前端一律按字符串处理 | | `droppedFleetItems[]` | Array | 没进草稿的车型项,见业务边界第 1 条 | | `droppedFleetItems[].orderId` / `orderNo` | String / String | 所属子订单 | | `droppedFleetItems[].vehicleType` / `seats` / `count` | String / Integer / Integer | 被丢弃项(子订单原值) | | `droppedFleetItems[].keptVehicleType` | String | 该户被归入的主车型 | | `droppedFleetItems[].reason` | String | `NOT_PRIMARY_TYPE`(非主车型)/ `VEHICLE_TYPE_NOT_IN_DICT`(车型不在车型字典内) | | `staleHeadcountOrders[]` | Array | 实时人数与子订单需求提交时冻结的人数不一致的户:`orderId` / `orderNo` / `frozenHeadcount` / `liveHeadcount` | | `paddedOrderDays[]` | Array | 为了覆盖该户「出发~返回」每一天而补进分组、但不在该户行程用车服务日里的日期:`orderId` / `orderNo` / `dates[]` | | `violations[]` | Array | 草稿按保存时同一套校验预检出的问题:`code` / `reason` / `detail` / `groupCode` / `tripDate` / `orderId`。正常应为空数组 | #### 请求示例 ```http GET /v3/admin/order/group-batch/2102692937584513025/vehicle-requirement/aggregate-draft Authorization: Bearer ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2102692937584513025", "currentStatus": null, "draft": { "version": null, "remark": null, "groups": [ { "groupId": null, "groupCode": "BUS", "vehicleType": "bus", "serviceStartDate": "2026-11-24", "serviceEndDate": "2026-11-26", "seats": 16, "count": 1, "specialTags": [], "remark": "HL20260923173356872: 8220 甲户多车型;HL20260923173356872 另报 suv 5座×1;HL20260923173401731: 8220 乙户", "days": [ { "tripDate": "2026-11-24", "headcount": 5, "memberOrderIds": [ "2102692937378992129", "2102692957587165185" ] }, { "tripDate": "2026-11-25", "headcount": 5, "memberOrderIds": [ "2102692937378992129", "2102692957587165185" ] }, { "tripDate": "2026-11-26", "headcount": 5, "memberOrderIds": [ "2102692937378992129", "2102692957587165185" ] } ] }, { "groupId": null, "groupCode": "SUV", "vehicleType": "suv", "serviceStartDate": "2026-11-24", "serviceEndDate": "2026-11-26", "seats": 5, "count": 1, "specialTags": [], "remark": "HL20260923173405165: 8220 丙户", "days": [ { "tripDate": "2026-11-24", "headcount": 2, "memberOrderIds": [ "2102692971709370370" ] }, { "tripDate": "2026-11-25", "headcount": 2, "memberOrderIds": [ "2102692971709370370" ] }, { "tripDate": "2026-11-26", "headcount": 2, "memberOrderIds": [ "2102692971709370370" ] } ] } ] }, "droppedFleetItems": [ { "orderId": "2102692937378992129", "orderNo": "HL20260923173356872", "vehicleType": "suv", "seats": 5, "count": 1, "keptVehicleType": "bus", "reason": "NOT_PRIMARY_TYPE" } ], "staleHeadcountOrders": [], "paddedOrderDays": [], "violations": [] }, "success": true } ``` **示例说明**:示例值取自测试环境一次真实调用,不构成可复现夹具。 #### 空数据 / 降级响应 团里没有需要用车的户,也没有任何 TRAVEL 需求时,返回空草稿(**`groups` 是空数组,不是 null**): ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2102692937584513025", "currentStatus": null, "draft": { "version": null, "remark": null, "groups": [] }, "droppedFleetItems": [], "staleHeadcountOrders": [], "paddedOrderDays": [], "violations": [] }, "success": true } ``` 车型字典(车队服务)不可用时**不降级**,返回 809120,见错误响应。 #### 错误响应 **有户缺少可汇总的行程用车需求**(未提交 / 被打回未重提 / 车型都不在字典内 / 推不出日期 / 人数为 0): ```json { "code": 809121, "message": "团期 2102692937584513025 有 1 户缺少可汇总的行程用车需求,暂不能自动汇总:HL20260923173405165(2102692971709370370):未提交行程用车需求", "data": null, "success": false } ``` **车型字典暂不可用**: ```json { "code": 809120, "message": "车队车型字典暂不可用,无法校验车型,请稍后重试", "success": false, "data": null } ``` **无权限**(当前角色没有 `group-batch:demand:confirm`): ```json { "code": 589507, "message": "无操作权限(当前角色未授予团期权限,或该团期不在您名下)", "success": false, "data": null } ``` #### 业务边界 - **多车型户**:一户的行程用车同时报了多个车型(如 bus×1 + suv×1)时,这一户只进「主车型」组。主车型取座位数×辆数最大的那一项,相等时取车型编码字典序小的,所以同一份数据每次汇总结果都相同。其余车型进 `droppedFleetItems`,🔴 **前端必须醒目提示**「以下车型需求未包含在草稿中」。这样做是因为同一户同一天只能属于一个分组(809108)。 - **拆组**:同一车型的日期如果断开(比如甲 10-08~10-09、乙 10-12~10-13),会拆成 `BUS` / `BUS2` 两组,不会产出某天零人的逐日行。 - **补日**:团级保存要求每个需车户「出发~返回」的每一天都被分组覆盖(809109)。子订单行程用车的服务日如果比这个范围窄,多出来的日期也会把该户算进车,并列在 `paddedOrderDays` 里。前端提示「以下户在这些日期原本未报用车,已按行程补入」。 - **人数**:逐日人数用订单**实时**人数,与户列表显示的人数一致。实时人数与子订单需求提交时冻结的人数不一致时,该户进 `staleHeadcountOrders`,说明那户的子订单用车需求已经过期,车务在子订单侧看到的还是旧值。 - **座位与车数**:`count` 按扣掉司机座的口径算,所以草稿在团级(809116)和子订单级两种座位校验下都成立。 - **`violations` 为空也不保证保存一定成功**:`currentStatus` 不是 null 或 `DRAFT`、或者汇总之后有人改过正式需求(version 变了),保存照样会被拒。 - **只汇总行程用车(TRAVEL)**,接送机(TRANSFER)不进团车。 - **本端点零写入**:不取锁、不进事务,调多少次都不影响数据。 --- ## 四、契约约束与正确调用方式 ### ✅ 正确 / ❌ 错误请求对照 | 场景 | 请求 | 预期(HTTP 恒 200) | |------|------|------| | ✅ 各户都已提交 | `GET .../{groupBatchId}/vehicle-requirement/aggregate-draft` | `code: 200`,`draft.groups` 非空 | | ✅ 原样保存 | `PUT .../{groupBatchId}/vehicle-requirement`,请求体 = `data.draft` | `code: 200` | | ❌ 有户未提交 | 同上 GET | `code: 809121`,报文列出未提交的户 | | ❌ 路径传成产品班期 ID | `GET .../{productBatchId}/vehicle-requirement/aggregate-draft` | 团期不存在的错误码(589500) | ### 前端「乘车户全选 + 人数自动汇总」的取数口径(wx 反馈第 ③ 条) - **户清单与每户人数**:用既有的 `GET /v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households?kind=TRAVEL`,人数取 `households[].participantCount`。它与本端点草稿里的逐日人数同源,都是订单实时人数。 - 🔴 **不要用分页的团期订单列表做「全选」**:那个列表单页上限 200,户数超过 200 时会漏选。`vehicle-households` 不分页,单次最多 500 户。 - **某天的人数** = 当天勾选各户的 `participantCount` 之和。汇总草稿里的 `days[].headcount` 就是按这个口径算的,可以直接对照。 --- ## 五、数据库行为 本接口**只读**,没有任何数据库写入,不新增表、不改表结构,也没有 Flyway 脚本。测试环境实测:调用前后,团级正式需求相关表与子订单用车需求表的行数和 `update_time` 都没有变化(见第八节)。 --- ## 六、边界行为 - **鉴权**:需要 `group-batch:demand:confirm`,与 `GET/PUT .../vehicle-requirement` 同一个权限码。不带 token 时网关返回 401;角色没有该权限时返回 `code: 589507`。 - **网关路由**:沿用现有的 `- Path=/v3/admin/**` → `lb://hl-order-service-v3`,本单**不新增路由**。 - **团期不存在** → 589500。 - **有户缺少可汇总需求** → 809121;**车型字典不可用** → 809120。 --- ## 六.5 枚举 / 数据字典 ### droppedFleetItems[].reason **所属字段**: `droppedFleetItems[].reason` | **类型**: `String` | 值 | 中文 | 说明 | |---|---|---| | `NOT_PRIMARY_TYPE` | 非主车型 | 该户多车型,只保留主车型 | | `VEHICLE_TYPE_NOT_IN_DICT` | 车型不在字典 | 车型归一后不在车队车型字典内 | --- ## 六.6 修改前后对比 无,本接口为新增。 --- ## 六.7 影响评估 - **破坏兼容**:否,新增接口 - **前端同步上线要求**:否,不接入也不影响现有编辑与保存流程 - **新增错误码**:`809121`(有户缺少可汇总的行程用车需求) --- ## 七、不影响范围 - **仅影响**:团期正式行程用车需求编辑弹窗的「自动汇总」入口 - **零影响**: - 正式用车需求的保存 / 读取 / 撤回 / 免车 / 确认(校验语义一处未改) - 子订单用车需求的提交与审核 - 接送机(TRANSFER)链路 --- ## 八、测试环境已验证 真实网关调用(`https://api.test.1814.love`),hl-order-service-v3 已部署 `dev-v3 @ 9b60bc62b`(本单合并提交,两个实例都在 17:21 滚动重启)。夹具全部自建:产品班期 `2102692899017912323`,团期 `2102692937584513025`,三户订单甲 / 乙 / 丙。 ``` GET .../2102692937584513025/vehicle-requirement/aggregate-draft (三户都没提交 TRAVEL) → 809121,报文列出 3 户 ✓;甲户提交后列 2 户 ✓;乙户提交后列 1 户 ✓ GET .../aggregate-draft (三户都已提交:甲 bus 16×1 + suv2 5×1、乙 bus 12×1、丙 suv2 5×1) → 200:BUS 组(甲 + 乙,seats=16,count=1,每天 5 人)+ SUV 组(丙); droppedFleetItems = 甲户 suv 5×1(NOT_PRIMARY_TYPE);violations = [] ✓ PUT .../2102692937584513025/vehicle-requirement 请求体 = data.draft 原样 → 200,version=1;GET 回读后逐字段比对与草稿一致 ✓(再汇总一次、带 version=1 原样保存 → 200,version=2 ✓) 零写入:调汇总前后各读一次 order_group_vehicle_requirement / _group / _group_day / order_vehicle_requirement 的全表行数与 MAX(update_time),以及本团相关行 → 两轮前后完全一致 ✓ 丙户另外提交了 TRANSFER(mpv,11-23 接机)→ 草稿里没有 mpv,也没有 11-23 ✓ 不带 token → code 401「缺少有效的 Authorization 头」 ✓ 定制师角色(无 group-batch:demand:confirm)→ code 589507 ✓ ``` --- ## 九、相关历史 PR - #8221(车型字典与存量车型归一):本端点输出的车型同样要通过 809119 --- ## 十、相关文档 - 关联 Issue: [wx/HL#8220](https://git.1814.love/wx/HL/issues/8220) - 关联 PR: [wx/HL#8273](https://git.1814.love/wx/HL/pulls/8273) --- ## 关联 / 联系人 ### 链接 - **Issue**: [#8220](https://git.1814.love/wx/HL/issues/8220) - **PR**: [#8273](https://git.1814.love/wx/HL/pulls/8273) - **Merge commit**: [9b60bc62b](https://git.1814.love/wx/HL/commit/9b60bc62b) ### 联系人 - **后端负责人**: @jw