--- schema: "hl-changelog/v2" ticket: "8654" title: "团期正式派车司机投影到人员配置表,DRIVER 角色改系统托管" consumer: "admin" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "pending" frontend_owner: "" frontend_ref: "" target_release: "" verified_at: "2026-09-30" status_note: "" updated_at: "2026-09-30" base: "dev-v3" --- # 团期人员配置: 正式派车司机投影到人员配置表,DRIVER 改系统托管 > **存放目录**: `changelogs-v2/2026-09/` > > **服务**: hl-order-service-v3 > **PR**: #8669 > **Issue**: #8653, #8654 > **日期**: 2026-09-30 > **影响范围**: 管理后台团期人员配置页、团期核单按团看人的名册读取 --- ## ⚠️ 关键变化 **团期人员配置表 `order_batch_staff` 现在会有系统自动生成的 DRIVER 行(司机),前端必须适配两个关键变化:** 1. **同一批数据的两个读口口径不同**: 保存接口响应里**不含**司机行,但查询接口(`getConfig` / 名册读取)**含**司机行。前端不要假设响应即全量。 2. **司机行不可编辑**: 这些 DRIVER 行由车务派车自动投影产生,不是运营配置的,前端不要在人员编辑表单提供编辑/删除入口。 3. **新的拒绝错误**(582120):保存请求的 `staffList` 或 `scopeRoles` 里出现 DRIVER,后端会拒绝**整批保存、零写入**。 --- ## 一、背景 工单 #8653 与 #8654 合并的功能:**把车务派车产生的司机自动投影到团期人员配置表**,供核单时按团看人、按天算账。 此前司机事实分散在每一户订单的用车需求上,现在统一投影到团期维度,简化核单逻辑。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 保存团期 staff 配置 | PUT | `/v3/admin/group-batch/{productBatchId}/staff` | 新增错误码 + 响应内容变化 | DRIVER 行系统管理,拒绝人工写入 | --- ## 三、接口详情 ### 1. 保存团期 staff 配置 `PUT /v3/admin/group-batch/{productBatchId}/staff` **VO**: `BatchStaffConfigReqVO → BatchStaffConfigRespVO` #### 使用场景 管理后台团期人员配置页,点「保存」按钮时调用此接口保存本期的团队成员(导游、摄影等)。支持按配置位(导游位 GUIDE+LEADER / 摄影位 PHOTOGRAPHER)分范围保存,避免一个弹窗保存时把另一个弹窗的既有数据清空。 保存成功后异步扇出到团内所有活跃订单的人员分配表 `order_staff_assignment`(source=GROUP_BATCH)。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | productBatchId | Path | Long | ✅ | 必须是有效的产品侧排期 ID | 团期所属产品侧班期 ID(非运营团期主键,由 group_tour_batch.batch_id 对应) | | staffList | Body | List | ✅ | 非 null;显式传 [] 表示清空,不能省略 | 本次保存覆盖范围内的最终成员列表。**禁止含 staffRole=DRIVER 的行**(582120)。若覆盖范围是导游位,必须同时列出 GUIDE 与 LEADER 两个角色成员(工单 #8122)。 | | staffList[].staffId | Body | Long | ✅ | 有效的员工 ID | 用户域员工 ID | | staffList[].staffRole | Body | String | ✅ | 取值: LEADER / GUIDE / PHOTOGRAPHER / OTHER / GUIDE_ASSISTANT / STUDY_TEACHER / LIFE_TEACHER;**不可含 DRIVER**(582120) | 员工角色。司机行由车务派车自动投影,一律不由本接口写入。 | | staffList[].sortOrder | Body | Integer | ❌ | 缺省 0 | 显示排序值(升序排列) | | staffList[].remark | Body | String | ❌ | ≤500 字符;null 表示保留原值,传空串清空 | 备注,仅供参考 | | staffList[].serviceStartDate | Body | LocalDate | ❌ | yyyy-MM-dd;不传时保留下来的人沿用原值、新选人员跟随团期;传值若与团期出发日相同则存 null(即跟随团期) | 有效服务开始日(#8468)。若自定义则必须在团期出发日到结束日之间,否则 582119 拒绝。 | | staffList[].serviceEndDate | Body | LocalDate | ❌ | yyyy-MM-dd;规则同 serviceStartDate;对照团期结束日 | 有效服务结束日(#8468)。 | | scopeRoles | Body | List | ❌ | 元素不能为 null / 空串 / 纯空白;传了必须 size ≥ 1 | 本次保存覆盖的角色范围。不传 = 整期全量覆盖(历史行为);传了 = 只覆盖这些角色,范围外既有行不动(工单 #8006)。导游位必须同时传 GUIDE 与 LEADER(工单 #8122),只传一个拒绝 582116 且零写入。staffList 里出现范围外角色拒绝 582115 且零写入。**禁止传 DRIVER**(582120)。 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | data.productBatchId | Long | 路径参数回显 | | data.groupBatchId | Long | 运营团期 ID(order_group_batch 主键) | | data.staffList | List | 本次保存后**保留下来的整期非司机成员**的最新快照。**不含 DRIVER 行**(工单 #8654)。 | | data.staffList[].id | Long | 记录 ID(batch_staff_id) | | data.staffList[].staffId | Long | 员工 ID | | data.staffList[].staffRole | String | 员工角色(LEADER / GUIDE / PHOTOGRAPHER 等,响应侧**不含 DRIVER**) | | data.staffList[].staffRoleName | String | 员工角色中文名(「导游」「领队」等;取值不在字典内时回落原 code) | | data.staffList[].staffName | String | 员工姓名(配置时快照) | | data.staffList[].staffPhone | String | 员工手机(脱敏:前 3 后 4,如 `138****6677`) | | data.staffList[].avatarUrl | String | 头像 URL(配置时快照) | | data.staffList[].sortOrder | Integer | 显示排序值 | | data.staffList[].remark | String | 备注 | | data.staffList[].reporterRank | String | 报账人等级:PRIMARY(主) / SECONDARY(次) / NONE(非报账人) | | data.staffList[].reporterRankName | String | 报账人等级中文名 | | data.staffList[].serviceStartDate | LocalDate | 有效服务开始日(未自定义时 = 团期出发日,团期改期后跟着变;#8468) | | data.staffList[].serviceEndDate | LocalDate | 有效服务结束日(规则同上) | | data.staffList[].serviceDateCustom | Boolean | 是否自定义过服务日期;true 时开始/结束至少一个有自定义值 | | data.staffList[].baseDailyWage | BigDecimal | 基础日薪(选人时从人员档案快照带出,团期内不可改;仅供参考;单位元;金额以字符串格式返回) | | data.staffList[].occupancyStatus | String | 占用状态(#8468):FREE / PARTIAL / FULL;按本人有效服务日期段比对其他团期与直派订单;本团未建或日期不完整时为 null;仅提示,不拦截 | | data.staffList[].occupiedDays | Integer | 被占天数(两端都算);null 时为 0 | | data.staffList[].freeRanges | List | 可派日期段列表(本人有效日期段内未被占的连续区间);恒非 null;全程占用时为 [] | | data.staffList[].occupancies | List | 占用明细数组;恒非 null;空闲时为 [] | | data.affectedOrderCount | Integer | 扇出影响的订单数(已触发异步写入的活跃子订单数) | #### 请求示例 ```http PUT /v3/admin/group-batch/80001/staff HTTP/1.1 Host: admin-api.test.example.com Authorization: Bearer {token} Content-Type: application/json { "scopeRoles": ["GUIDE", "LEADER"], "staffList": [ { "staffId": 40001, "staffRole": "LEADER", "sortOrder": 0, "remark": "首席领队", "serviceStartDate": "2026-10-01", "serviceEndDate": "2026-10-06" }, { "staffId": 40002, "staffRole": "GUIDE", "sortOrder": 1, "remark": null, "serviceStartDate": null, "serviceEndDate": null } ] } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "productBatchId": 80001, "groupBatchId": 90211, "staffList": [ { "id": 770001, "staffId": 40001, "staffRole": "LEADER", "staffRoleName": "领队", "staffName": "刘领队", "staffPhone": "138****6677", "avatarUrl": "https://example.com/avatar/40001.jpg", "sortOrder": 0, "remark": "首席领队", "reporterRank": "PRIMARY", "reporterRankName": "主报账人", "serviceStartDate": "2026-10-01", "serviceEndDate": "2026-10-06", "serviceDateCustom": true, "baseDailyWage": "600.00", "occupancyStatus": "PARTIAL", "occupiedDays": 2, "freeRanges": ["2026-10-01~2026-10-03", "2026-10-05~2026-10-06"], "occupancies": [ { "groupBatchId": 90212, "groupBatchName": "十一国庆游 V2 期", "conflictDates": ["2026-10-02", "2026-10-04"] } ] }, { "id": 770002, "staffId": 40002, "staffRole": "GUIDE", "staffRoleName": "导游", "staffName": "王导游", "staffPhone": "138****5678", "avatarUrl": "https://example.com/avatar/40002.jpg", "sortOrder": 1, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人", "serviceStartDate": "2026-10-01", "serviceEndDate": "2026-10-06", "serviceDateCustom": false, "baseDailyWage": "500.00", "occupancyStatus": "FREE", "occupiedDays": 0, "freeRanges": ["2026-10-01~2026-10-06"], "occupancies": [] } ], "affectedOrderCount": 3 }, "success": true } ``` #### 空数据 / 降级响应 整期清空后的响应(`staffList=[]` 加 `scopeRoles` 不传): ```json { "code": 200, "message": "成功", "data": { "productBatchId": 80001, "groupBatchId": 90211, "staffList": [], "affectedOrderCount": 3 }, "success": true } ``` #### 错误响应 **错误码 582120**(司机行系统管理,拒绝人工写入): ```json { "code": 200, "message": "司机由车务派车自动带入团期人员,不能在这里新增或删除;如需调整请到车务派单中维护", "success": false, "data": null } ``` **错误码 582116**(导游位只传半个配置位): ```json { "code": 200, "message": "团期人员配置位 GUIDE 成员缺失,导游位(导游+领队)须同时声明两个角色", "success": false, "data": null } ``` **错误码 582115**(staffList 包含 scopeRoles 范围外的角色): ```json { "code": 200, "message": "员工角色 PHOTOGRAPHER 不在覆盖范围 [GUIDE,LEADER] 内,请修正请求", "success": false, "data": null } ``` **错误码 582119**(服务日期校验失败): ```json { "code": 200, "message": "刘领队 的服务日期(2026-10-06 至 2026-10-05)不合法:开始日期不能晚于结束日期,且须在团期日期(2026-10-01 至 2026-10-10)之内", "success": false, "data": null } ``` #### 业务边界 - **权限**: 接口接 `GroupBatchPermissionGuard.PERMISSION_MANAGE`,需要团期管理权限;权限校验在 Controller 层,拒绝时零写入。 - **幂等性**: 同一请求重复提交视为覆盖保存,第二次提交时无新改动则库表无变化、响应同样 200。 - **扇出并发**: 保存成功后异步扇出到团内全部活跃订单的 `order_staff_assignment(source=GROUP_BATCH)`,前端无需等待此异步过程即可收到 200。 - **DRIVER 行系统管理**: 司机行由车务派车通过 `GroupBatchDriverProjectionService` 自动投影维护,人工配置侧严禁涉及。staffList 或 scopeRoles 里出现 DRIVER 一律拒绝(582120),**整批保存零写入**,连软删都不执行。 - **两个读口口径不同**: - 本接口(PUT)保存成功后返回的 `staffList` **不含司机行**(只含人工配置的非 DRIVER 角色)。 - 查询接口 `GET /v3/admin/group-batch/{productBatchId}/staff` 与单人查询 `GET .../staff/{staffId}` **含司机行**。 - 前端不要假设响应即全量,名册读取时必须从查询接口获取完整名单(含司机)。 - **团期状态检查**: 入口第一步检查团期成团状态(未建团 589553 / 已确认或已取消 589598);不满足直接拒,代码不走到保存逻辑。 - **服务日期有效期**: serviceStartDate 与 serviceEndDate 必须满足: - 开始日 ≤ 结束日 - 双双在团期出发日到结束日之间 - 违反任一条拒绝 582119,**整批零写入**。 --- ## 四、契约约束与正确调用方式 ### ✅ 正确 / ❌ 错误 payload 对照 | 场景 | payload | 结果 | |------|---------|------| | ✅ 导游位全量覆盖 | `{ "scopeRoles": ["GUIDE","LEADER"], "staffList": [{staffId:40001, staffRole:"LEADER"}, {staffId:40002, staffRole:"GUIDE"}] }` | 200,只有这两人留在 GUIDE 和 LEADER 行,其他既有行不动 | | ✅ 清空整期 | `{ "staffList": [] }` (不传 scopeRoles) | 200,整期人员全清,库表此后仅含司机行 | | ✅ 导演位自定义服务日期 | `{ "staffList": [{staffId:40001, staffRole:"LEADER", serviceStartDate:"2026-10-01", serviceEndDate:"2026-10-05"}] }` | 200 | | ❌ 导游位只传半个配置位 | `{ "scopeRoles": ["GUIDE"], "staffList": [...] }` | 拒绝 582116、零写入(缺少 LEADER 配角) | | ❌ staffList 包含 DRIVER | `{ "staffList": [{staffId:40001, staffRole:"DRIVER"}] }` | 拒绝 582120、零写入(司机由车务派车投影) | | ❌ scopeRoles 包含 DRIVER | `{ "scopeRoles": ["DRIVER"], "staffList": [] }` | 拒绝 582120、零写入 | | ❌ 服务开始日晚于结束日 | `{ "staffList": [{staffId:40001, staffRole:"GUIDE", serviceStartDate:"2026-10-05", serviceEndDate:"2026-10-01"}] }` | 拒绝 582119、零写入 | | ❌ 服务日期越出团期 | `{ "staffList": [{staffId:40001, staffRole:"GUIDE", serviceStartDate:"2026-09-30"}] }` (团期出发日 2026-10-01) | 拒绝 582119、零写入 | ### scopeRoles 的语义 - **不传**: 整期全量覆盖。staffList 即为团期的最终全量人员配置(除去司机),其他所有角色的既有行会被软删。 - **传了**: 仅覆盖声明的角色。比如只想更新导演位不动摄影位,传 `["GUIDE","LEADER"]` 即可;摄影位的既有行保持不变。 - **导游位特殊性**: 因为导游位同时收 GUIDE 与 LEADER 两个角色,声明导游位必须两个都传。只传其中一个会被拒(582116)——因为「少那一个」意味着覆盖范围不完整,不能正确表达"我只想改导游位"的意图。 - **DRIVER 禁令**: scopeRoles 里出现 DRIVER 拒绝 582120。虽然 DRIVER 在结构上不属于任何配置位(系统管理),但这条禁令是恒定的——不能通过改 scopeRoles 来迂回删除司机行。 --- ## 五、数据库行为 保存请求成功后的库表变化: | 操作 | 对象 | 行为 | |------|------|------| | 软删 | `order_batch_staff` | 按 scopeRoles(若不传则整期)清除人工行,**不删司机行**(502120 保护) | | 插入 | `order_batch_staff` | 按 staffList 新增非 DRIVER 行 | | 异步扇出 | `order_staff_assignment(source=GROUP_BATCH)` | 更新团内活跃订单的 GROUP_BATCH 副本,内容为最新的整期非司机成员 | **核心不变量**: 人工配置(GroupBatchStaffConfigService)**只碰非 DRIVER 行**;司机投影(GroupBatchDriverProjectionService)**只碰 DRIVER 行**。两边各管各的子集,交集为空。 --- ## 六、边界行为 - **未登录** → 401(网关拦截) - **无团期管理权限** → 403(权限校验) - **团期不存在** → 589553(未建团)或 589598(已确认/已取消) - **下游服务(人员服务、图片服务)降级** → 快照字段(姓名、手机、头像)回落配置时快照,接口仍 200;不阻断保存流程 - **并发冲突** → 保存成功(最后提交的版本胜出,不走 CAS),查询时可能看到中间态 - **司机行来自** → 由 `fleet-service` 派车回调、经 `GroupBatchDriverProjectionService` 投影维护,非人工配置 --- ## 六.5、枚举 ### staffRole(员工角色) **所属字段**: `staffList[].staffRole` (请求) / `data.staffList[].staffRole` (响应) | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `LEADER` | 领队 | 导游位成员之一 | | `GUIDE` | 导游 | 导游位成员之一(与 LEADER 并收) | | `PHOTOGRAPHER` | 摄影 | 摄影位成员 | | `GUIDE_ASSISTANT` | 导游助理 | 其他配置位成员 | | `STUDY_TEACHER` | 研学老师 | 其他配置位成员 | | `LIFE_TEACHER` | 生活老师 | 其他配置位成员 | | `OTHER` | 其他 | 杂项角色(不属于任何标准配置位) | | `DRIVER` | 司机 | **本接口禁止人工写入**(582120);由车务派车自动投影;查询时可见 | ### reporterRank(报账人等级) **所属字段**: `data.staffList[].reporterRank` (响应) | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `PRIMARY` | 主报账人 | 团期内唯一;预支款打给此人 | | `SECONDARY` | 次报账人 | 团期内唯一;备选收款人 | | `NONE` | 非报账人 | 缺省值;不参与结算 | ### occupancyStatus(占用状态) **所属字段**: `data.staffList[].occupancyStatus` (响应) | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `FREE` | 空闲 | 有效服务日期段内无其他团期或订单占用 | | `PARTIAL` | 部分占用 | 有效日期段内某些天被占,某些天可派 | | `FULL` | 全程占用 | 有效日期段全部被占,无可派日期 | | `null` | - | 团期未建成或服务日期不完整时为 null;仅提示,不拦截提交 | --- ## 六.6、修改前后对比 ### 接口逻辑变化 | 维度 | 改前 | 改后 | |------|------|------| | **司机行管理** | 团期人员配置由运营全手工维护,无系统投影 | 司机行由车务派车自动投影,人工配置侧严禁涉及 | | **保存响应** | 返回整期人员全量(包含一切手工配置) | 返回**非司机成员**快照(DRIVER 行被筛除) | | **查询响应** | 同保存响应 | **含司机行**(与保存响应口径不同) | | **错误码新增** | 无 DRIVER 相关拒绝 | 新增 582120:司机行拒绝码,整批零写入 | | **权限校验** | 无 | 新增 Controller 层权限校验(PERMISSION_MANAGE),拒绝时零写入 | ### 字段级变化 | 字段 | 改前状态 | 改后状态 | |------|----------|----------| | `serviceStartDate` / `serviceEndDate` | 无此字段 | 新增可选字段;支持按人自定义服务有效期 | | `serviceDateCustom` | 无 | 新增,标记是否自定义过服务日期 | | `baseDailyWage` | 无 | 新增,人员档案快照(配置时带出,不可修改) | | `occupancyStatus` / `occupiedDays` / `freeRanges` | 无 | 新增,占用查询结果(#8468 D8,仅提示不拦截) | --- ## 六.7、影响评估 - **是否破坏向后兼容**: 是(一定程度) - 响应体新增字段:`serviceStartDate` / `serviceEndDate` / `serviceDateCustom` / `baseDailyWage` / `occupancyStatus` / `occupiedDays` / `freeRanges` / `occupancies`,但字段全可空,字段层兼容。 - 响应 `staffList` 内容变化:**不再包含司机行**(之前如果有系统产生的司机行会出现,现在被筛除)。前端若依赖"返回即全量"会破损。 - 新增错误码 582120:拒绝 DRIVER 行,整批零写入——改动前的正常请求可能现在被拒。 - **前端是否必须同步上线**: 是 - 如果前端在"人员名册""核单名单"等读取页面会显示司机信息,必须从查询接口(GET)而非缓存保存接口的响应来获取;否则缺司机行。 - 如果前端在人员编辑表单提供了 DRIVER 行的编辑/删除入口,必须移除,改为呈现"这是由车务派车产生的"提示。 - UI 需要适配新字段(服务日期、日薪、占用)的显示。 - **前端 workaround 清理点**: - 若前端之前硬编码了"司机只能通过XX页面配置",现在这句不再对。 - 若前端假设"保存响应即全量名册"来渲染名册卡片,需改为调查询接口。 - 若前端在人员编辑表单里有 DRIVER 选项,需删除;前端用户无法(也不应该)在这里新增/删除司机。 --- ## 七、不影响范围 - **仅影响**: 管理后台「团期人员配置」页面与「团期核单」按团看人的名册展示 - **零影响**: - 订单侧人员分配(仍独立维护 `order_staff_assignment(source=ORDER)`) - 车务派车流程(按 fleet 业务正常发车、自动投影) - 人员候选列表查询(`GET .../staff/candidates`)的返回格式 - 团期总体状态、成团判断、团期改期逻辑 - 后端其他模块对人员表的现有查询(如财务核对) --- ## 八、测试环境已验证 以 productBatchId=80001(groupBatchId=90211)为例,真实接口测试: ``` PUT /v3/admin/group-batch/80001/staff Request: scopeRoles=["GUIDE","LEADER"], staffList=[{staffId:40001, staffRole:"LEADER"}] → 200 OK ✓ GET /v3/admin/group-batch/80001/staff → 200 OK,staffList 含导游位成员及系统投影司机行 ✓ PUT /v3/admin/group-batch/80001/staff Request: staffList=[{staffId:40001, staffRole:"DRIVER"}] → 拒绝 582120(司机由车务派车自动带入...)✓ PUT /v3/admin/group-batch/80001/staff Request: scopeRoles=["GUIDE"], staffList=[...](缺 LEADER) → 拒绝 582116(导游位成员缺失...)✓ ``` --- ## 九、相关历史 PR | PR | Issue | 说明 | 是否仍有效 | |----|-------|------|-----------| | #8669 | #8653, #8654 | 本次变更:司机投影落地,DRIVER 改系统管理 | ✅ 最新 | --- ## 十、相关文档 - 关联 Issue: [#8653](https://git.1814.love:8443/wx/HL/issues/8653), [#8654](https://git.1814.love:8443/wx/HL/issues/8654) - 关联 PR: [#8669](https://git.1814.love:8443/wx/HL/pulls/8669) - Merge commit: [5c50782717d6](https://git.1814.love:8443/wx/HL/commit/5c50782717d6) --- ## 关联 / 联系人 ### 链接 - **Issue**: [#8653](https://git.1814.love:8443/wx/HL/issues/8653) / [#8654](https://git.1814.love:8443/wx/HL/issues/8654) - **PR**: [#8669](https://git.1814.love:8443/wx/HL/pulls/8669) - **Merge commit**: [5c50782717d6](https://git.1814.love:8443/wx/HL/commit/5c50782717d6) ### 联系人 - **后端负责人**: @wx