diff --git a/changelogs-v2/2026-09/22_8159_团级配车成员共用候选查询-新增接口-管理后台.md b/changelogs-v2/2026-09/22_8159_团级配车成员共用候选查询-新增接口-管理后台.md index 714a1325..54d529d2 100644 --- a/changelogs-v2/2026-09/22_8159_团级配车成员共用候选查询-新增接口-管理后台.md +++ b/changelogs-v2/2026-09/22_8159_团级配车成员共用候选查询-新增接口-管理后台.md @@ -19,7 +19,7 @@ base: "dev-v3" # 团级配车成员共用候选查询(#8159) -> **服务**: hl-fleet-service(端口 8060) +> **服务**: hl-fleet-service(经网关调用,无需关心服务端口) > **PR**: #8188 > **Issue**: #8159 > **日期**: 2026-09-22 @@ -29,7 +29,13 @@ base: "dev-v3" ## ⚠️ 关键变化 -新增查询端点返回「成员共用」候选列表。**重点提醒**:`resourceId` 字段语义需特别关注——未传时 `selectable` 返 `null`(未判定),**不能当 `false` 用禁用勾选**,也不能当 `true` 用放开勾选,需用户先确认资源维度。 +新增查询端点,返回「成员共用」的候选行清单,喂给已有的「确认共用关系」写口。 + +**三条会直接影响你怎么写代码的点,按严重度排**: + +1. 🔴 **`shareGroupId` 必须由前端比对,本接口不会替你做**(业务边界第 2 条)。编辑一个**已存在**的共用关系时,它自己的既有成员会被本接口置灰成 `ALREADY_IN_ANOTHER_GROUP`——前端要把 `shareGroupId` 等于「正在编辑的关系 ID」的行**恢复成可勾选**。并且**提交时 `members` 是全集不是增量**:漏掉的既有成员会被写口按「移出本关系」处理,**不报错、静默生效**。 +2. 🔴 **`occupying` / `selectable` 是三态,`null` 是「未判定」不是「否」**。不传 `resourceId` 时 `occupying` 恒 `null`、`selectable` **永不为 `true`**——但它**可能是 `false`**(已属别的关系、或团期身份缺失这两条浏览态就判得出来),所以置灰要照常渲染。 +3. **成功码是 `code: 200`,不是 `0`**;业务失败也返 HTTP 200,**一律按 `body.code` 判,不要按 HTTP 状态行判**。 --- @@ -66,7 +72,7 @@ base: "dev-v3" | `resourceType` | Query | String | ✅ | `VEHICLE` / `DRIVER` | 资源维度。缺失或非法值返 `code: 100001` | | `resourceId` | Query | Long | ❌ | — | 具体资源 ID(车 ID / 司机 ID)。**不传时 `occupying` / `selectable` 返 `null`**(未判定状态,见业务边界第 1 条) | -#### 出参 `Result>` +#### 出参 `Result>` | 字段 | 类型 | 说明 | |------|------|------| @@ -88,8 +94,8 @@ base: "dev-v3" | `occupiedDriverId` | String / null | 该行实际占用的司机 ID;未占用时 null | | `occupiedDriverName` | String / null | 该行实际占用的司机名称 | | `occupying` | Boolean / **null** | 本行在 `(serviceDate, resourceType, resourceId)` 上是否活跃占用。**`resourceId` 未传时为 `null`**(未判定) | -| `shareGroupId` | String / null | 本行所属的 ACTIVE 共用关系 ID;不属任何关系时 null | -| `selectable` | Boolean / **null** | 是否可勾选。`true` = 可选;`false` = 不可选(此时 `unselectableReason` 非空);**`null` = 未判定**(仅当 `resourceId` 未传、且本行未被永久性约束击中时出现) | +| `shareGroupId` | String / null | 本行所属的 ACTIVE 共用关系 ID;不属任何关系时 null。🔴 **调用方必须拿它和「正在编辑的关系 ID」比对**,见业务边界第 2 条 | +| `selectable` | Boolean / **null** | 是否可勾选。`true` = 可选;`false` = 不可选(此时 `unselectableReason` 非空);**`null` = 未判定**(`resourceId` 未传、且本行未被 `ALREADY_IN_ANOTHER_GROUP` / `CROSS_BATCH` 判死时出现)。🔴 **`resourceId` 未传时本字段永不为 `true`** | | `unselectableReason` | String / null | 不可选原因枚举值(见六.5 章节);`selectable=true` 时为 null | | `unselectableDetail` | String / null | 人类可读的补充说明,如「订单 HLxxxx 已在车 A 关系中」 | @@ -154,7 +160,7 @@ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candida } ``` -下游不可用(权威基线 Feign 失败,失败关闭)时,接口返 `code: 999` 或 5xx,网关记日志,前端需重试或展示加载失败。 +🔴 **区分「空」与「失败关闭」**:拿不到团期权威基线时,本接口**不降级返空**,而是**失败关闭**抛 `GROUP_BATCH_BASELINE_UNAVAILABLE`。⇒ `data: []` 只意味着「确实没有候选」,前端可以放心按「无可选成员」渲染,不必担心它其实是一次被吞掉的下游故障。 #### 错误响应 @@ -182,16 +188,42 @@ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candida #### 业务边界 -1. **`resourceId` 未传时的 null 三态语义**(最重要): - - `occupying`:**`null`**(= 未判定,不是 `false`) - - `selectable`:**`null`**(= 未判定,不是 `true` / `false`) - - `unselectableReason`、`unselectableDetail`:都为 null +**0. 本清单直接喂给写口**:返回的 `sourceType` + `sourceId` 就是「确认共用关系」写口 `members[]` 要的两个字段,**原样透传即可**,不需要再做映射。 - 前端在浏览态(用户尚未选择具体资源)**不能**把 `selectable == null` 当成 `false` 禁用勾选、也不能当成 `true` 放开;这是第三种状态,需等用户明确选定车或司机后再查一遍、获得真实判定。 +**1. `resourceId` 未传时的 `null` 三态语义** -2. **候选是时点快照**:本接口返回的 `selectable=true` 是**查询那一刻**的结论。前端提交共用关系时仍可能撞到 `code: 602106`(该行在这期间被别人加进了另一个共用关系)。前端**必须保留提交失败的错误处理分支**,不能因为候选查询说可选就假定提交一定成功。 +| 字段 | 传了 `resourceId` | 未传 `resourceId`(浏览态) | +|---|---|---| +| `occupying` | `true` / `false` | 恒 `null`(未判定) | +| `selectable` | `true` / `false` | `false` **或** `null`,**永不为 `true`** | +| `unselectableReason` | `selectable=false` 时非空 | 同左 | -3. **`group_batch_id` 为 NULL 的派单行不在本端点覆盖范围**:查询条件是 `eq(group_batch_id, ?)`,这类行无法查出;若前端观察到「某些派单没出现在候选里」,原因可能是该派单的 `group_batch_id` 字段为 null(结构性特征,非数据错误)。 +`null` 的含义是**未判定**,不是「否」。不传 `resourceId` 时「当日占没占着这个资源」根本算不出来,所以 `occupying` 一律 `null`。 + +🔴 **但浏览态下 `selectable` 并不总是 `null`**:有两条判据不依赖 `resourceId`,浏览态照样能判死—— +- **已属别的 ACTIVE 共用关系** → `selectable=false`,`unselectableReason=ALREADY_IN_ANOTHER_GROUP` +- **团期身份缺失**(`group_batch_id` / `requirement_id` 为空或团期不符)→ `selectable=false`,`unselectableReason=CROSS_BATCH` + +⇒ 前端在浏览态**不能**把 `selectable == null` 当成 `false` 禁用勾选,也**不能**当成 `true` 放开;但**要照常渲染 `selectable === false` 的置灰与原因**——那些行在浏览态就已经定死了,选了具体资源也不会变回可选。 + +**2. 🔴 `shareGroupId` 必须由调用方比对——这是契约的一部分,不是可选优化** + +本读口**不知道你正在编辑哪一个共用关系**,所以凡已属任一 ACTIVE 关系的行,一律置灰为 `ALREADY_IN_ANOTHER_GROUP`。而写口的 602106 拒的是「属于**另一个**关系」——**同一关系的既有成员,写口是收的**。 + +⇒ **给同一个 `shareGroupId` 追加第 3 个成员这个动作,本清单的置灰结构上覆盖不到。** 前端必须自己做两件事: + +1. **把 `shareGroupId` 等于「正在编辑的关系 ID」的行视为可勾选**(忽略本接口给它的 `ALREADY_IN_ANOTHER_GROUP` 置灰)。不做这一步的表现是:编辑一个已有关系时,它自己的现有成员全部不可选,功能做不出来。 +2. 🔴 **提交时 `members` 是全集不是增量**——要把该关系的**既有成员连同新成员一起**放进 `members`。**漏掉的既有成员会被写口按「移出本关系」处理。** 只提交新成员的写法不会报错,但会静默把原有成员全部踢出关系。 + +**3. `selectable=true` 不是「提交必成功」的承诺** + +本清单是**时点快照、不加锁**。返回之后别的车务仍可能把某成员纳入另一个关系,提交时照样可能撞 `602106`。 + +⇒ 这种 602106 请渲染成「**有人先一步占了,请刷新候选清单重选**」,🔴 **不要当系统故障自动重试——重试必然同样失败**。这条竞态读口消除不了(加锁也不行,锁在响应返回那一刻就已放开),最终由写口的锁定读与唯一键兜底。 + +**4. 覆盖范围:未关联团期的派单行结构上不在本清单内** + +本清单只列 `group_batch_id` 指向本团的派单行。`group_batch_id` 为空的派单行查不出来——但**这不是缺口**:这类行在写口同样恒被 602103 拒,「看不见」与「看得见也提交不了」**等价**。要让这类行可共用,得先补上团期身份(数据侧动作,不是前端能处理的)。 --- @@ -199,30 +231,41 @@ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candida ### ✅ 正确 / ❌ 错误请求对照 -| 场景 | 请求 | 预期 | +🔴 **先说判据**:本服务的业务失败与入参校验**一律返 HTTP 200**(`GlobalExceptionHandler` 带 `@ResponseStatus(HttpStatus.OK)`),**`body.code` 才是真相**。前端判错请一律读 `body.code`,**不要读 HTTP 状态行**——按状态行判会把所有业务错误当成成功。 + +| 场景 | 请求 | 预期(HTTP 恒 200) | |------|------|------| -| ✅ 浏览态,看全清单 | `?serviceDate=2026-12-25&resourceType=VEHICLE`(无 resourceId) | `data[]` 中所有行 `occupying=null, selectable=null` | -| ✅ 选定车后判可选 | `?serviceDate=2026-12-25&resourceType=VEHICLE&resourceId=2085539421276286978` | `data[]` 中各行 `occupying=true/false, selectable=true/false/null` | -| ✅ 跨服务日查询 | `?serviceDate=2026-12-26&resourceType=VEHICLE&resourceId=xxx` | 若 2026-12-26 在服务窗内,照常返回候选;若不在返 602104 | -| ❌ 维度错填 | `?serviceDate=2026-12-25&resourceType=HOUSE&resourceId=xxx` | 400 `100001: 参数非法: 资源维度非法: HOUSE` | -| ❌ 缺维度参数 | `?serviceDate=2026-12-25&resourceId=xxx`(无 resourceType) | 400 `100001: 参数非法: 资源维度不能为空` | +| ✅ 浏览态,看全清单 | `?serviceDate=2026-12-25&resourceType=VEHICLE`(无 resourceId) | `code: 200`;各行 `occupying=null`;`selectable` 为 `null` 或 `false`(被判死的行),**不会是 `true`** | +| ✅ 选定车后判可选 | `?serviceDate=2026-12-25&resourceType=VEHICLE&resourceId=2085539421276286978` | `code: 200`;各行 `occupying=true/false`,`selectable=true/false` | +| ✅ 同团其他服务日 | `?serviceDate=2026-12-26&resourceType=VEHICLE&resourceId=xxx` | 在该团服务日窗内则照常返回候选,否则 `code: 602104` | +| ❌ 维度错填 | `?serviceDate=2026-12-25&resourceType=HOUSE&resourceId=xxx` | `code: 100001`「参数非法: 资源维度非法: HOUSE」 | +| ❌ 缺维度参数 | `?serviceDate=2026-12-25&resourceId=xxx`(无 resourceType) | `code: 100001`「参数非法: 资源维度不能为空」 | +| ❌ 路径参数填成产品班期 ID | `/batches/{product_batch_id}/share-member-candidates?...` | 🔴 **不报参数错**,返 `code: 200` + `data: []`。失败形态是静默的,看起来像「接口没数据」 | --- ## 五、数据库行为 -本接口仅读操作,无 DB 写入。查询涉及表:`order_requirement`(派车行),`group_dispatch_line`(团级配车行),`group_batch`(团期基础),`vehicle`(车辆),`driver`(司机)。 +本接口**仅读**,无任何 DB 写入,不新增表、不改表结构、无 Flyway 脚本。 --- ## 六、边界行为 -- **未登录** → 401(网关统一鉴权) -- **无访问权限** → 403(订单顾问、财务相关角色可访) -- **团期不存在** → `data: []`(无候选) -- **资源维度非法** → 100001 -- **服务日超出团期窗** → 602104 -- **下游服务不可用** → 5xx,前端重试 +**鉴权**(🔴 本单专门订正过一处长期误解,前端排查 403 时会用到): + +- **未登录 / token 失效** → 网关 `JwtAuthFilter` 拦下 +- **已登录但角色不符** → `FleetAdminRoleGuardInterceptor` 对 `/admin/fleet/**` **只放行 `VEHICLE_MANAGER` 与 `SUPER_ADMIN` 两个角色** +- 🔴 **鉴权的真相是角色,不是权限点**:`fleet:group-dispatch:view` / `fleet:group-dispatch:write` 这两个字符串在整个 Java 侧**零引用**,它们只是权限字典里的声明,不是运行时判据。⇒ **「给某个非车务角色开个只读权限点,让他看候选清单」这件事配置不出来**——那类角色连 `/admin/fleet/**` 都进不来。要改授权粒度得动拦截器,是另一件事。 + +**其余边界**(一律 HTTP 200,按 `body.code` 判): + +- **资源维度缺失 / 非法** → `code: 100001` +- **服务日超出团期服务日窗** → `code: 602104` +- **团期权威基线拿不到** → **失败关闭**(不降级返空),见「空数据 / 降级响应」 +- **本团本服务日无候选** → `code: 200` + `data: []` + +**网关路由**:复用既有 `- Path=/admin/fleet/**` → `lb://hl-fleet-service`,本单**不新增路由**。 --- @@ -250,14 +293,16 @@ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candida **所属字段**: `unselectableReason` | **类型**: `String` -| 值 | 含义 | 触发条件 | -|---|---|---| -| `CROSS_BATCH` | 602103 | 本行所属团期与目标团期不同 | -| `NOT_OCCUPYING` | 602102 | 本行在目标 `(serviceDate, resourceType, resourceId)` 上未占用 | -| `ALREADY_IN_ANOTHER_GROUP` | 602106 | 本行已属于另一个 ACTIVE 共用关系 | -| `GROUP_DISPATCH_INVALID` | 602105 | 团级配车行的基础数据不完整或状态异常 | +| 值 | 触发条件 | 浏览态(未传 `resourceId`)能否判出 | 对应写口错误码 | +|---|---|---|---| +| `CROSS_BATCH` | `group_batch_id` / `requirement_id` **为空**,或团期与本团不符 | ✅ 能 | 602103 | +| `NOT_OCCUPYING` | 派单不在**可改派的在飞态**,或当日**标记不用车** | ❌ 不能(需 `resourceId`) | 602102 | +| `ALREADY_IN_ANOTHER_GROUP` | 本行已属某个 ACTIVE 共用关系(此时 `shareGroupId` **非空**) | ✅ 能 | 602106 | +| `GROUP_DISPATCH_INVALID` | 团级配车行**当日不在此资源上** | ❌ 不能(需 `resourceId`) | 602105 | -**说明**:这些值**不是**接口级错误码,而是本字段的业务枚举。提交共用关系时若失败,才会返回 `code: 602103` / `602106` 等(整请求级错误码)。 +**说明**:这一列值**不是**接口返回的 `code`,而是 `unselectableReason` 字段的取值。本接口只要能返回清单,`code` 恒为 `200`;上面那些 6021xx 是**提交共用关系**(写口)失败时才会出现在 `code` 里的。 + +🔴 **`ALREADY_IN_ANOTHER_GROUP` 这一行前端必须自己复算**:本读口不知道你在编辑哪个关系,会把正在编辑的那个关系的既有成员也置灰成它。判据是 `shareGroupId` 是否等于正在编辑的关系 ID,详见业务边界第 2 条。 ---