--- schema: "hl-changelog/v2" ticket: "7378" title: "新增团期行程只读端点 GB-ADM-018/019:逐日汇总 + 某项逐户下钻" consumer: "admin" author: "jw(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "4d016870" target_release: "" verified_at: "2026-09-09" status_note: "verified 2026-09-10 mmg(hl-admin 4d016870):团期详情新增「行程」Tab(物资后操作记录前,:active 惰性+589507 兜底)——逐日卡+三色一致性徽标(ALL_SAME 读确定值/非 ALL_SAME 读区间互不混读)+本日合计「仅含全团一致项,N 项未计入」常显+天数离群户预警;ItineraryNodeModal 下钻(nodeKey 原样回传+dayNumber 必带,零命中按未参加渲染,查看订单跳详情概览);无原型按既有 Tab 范式自定形态。scoped checkpoint 全绿(Vitest 全量+生产构建)。" updated_at: "2026-09-09" base: "dev-v3" --- # 团期行程逐日汇总与逐户下钻 ## 一、给前端的一句话 团期详情现在能按天看整团行程了。**一次 GET 拿到全团逐日行程**,每一项都标好了 「全团一致 / 都有但价量不同 / 只有部分户有」,点某一项再 GET 一次能下钻到**逐户**明细。 两个都是纯新增只读端点,不动任何既有接口,**团期层没有任何行程写口**—— 要改行程还是去那户的订单行程页(下钻每行都带 `orderId` 供跳转)。 ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|---|---|---|---|---| | 1 | 团期行程逐日汇总 | GET | `/v3/admin/order/group-batch/:groupBatchId/itinerary` | 新增 | 按天返回全团行程,每项带一致性标记与本日合计 | | 2 | 团期行程某项逐户下钻 | GET | `/v3/admin/order/group-batch/:groupBatchId/itinerary/nodes/:nodeKey` | 新增 | 某一项的逐户明细,含没有该项的户 | ## 三、接口详情 ### 1. 团期行程逐日汇总 `GET /v3/admin/order/group-batch/:groupBatchId/itinerary` **VO**: `GroupBatchItineraryRespVO` #### 使用场景 团期详情页新增的「行程」区块。运营出团前要一眼看出「整团第 3 天去哪」以及 「哪几户跟大部队不一样」。行程是**订单级**的:同一期客人产品相同、出发日相同, 创单事务内从产品快照展开固化,骨架本来一样;下单之后每户可以单独加项、改时间、调价。 这个接口把全团各户的行程按天叠在一起,标出哪里已经不一样了。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `groupBatchId` | path | Long | 是 | 雪花 ID,按字符串传 | 团期 ID | 无 query、无 body。**不分页**——单团期的天数与每天的项数是有限的行程结构,不是无界列表。 #### 出参 `Result`。 | 字段 | 类型 | 说明 | |---|---|---| | `groupBatchId` | String | 团期 ID(雪花) | | `totalHouseholds` | Integer | 参与统计的活跃户数(分母 M);已取消 / 已退单户不计入 | | `dayCountConsistent` | Boolean | 各户行程天数是否一致 | | `dayCount` | Integer | **多数户**的行程天数;无行程时 `0` | | `dayCountOutliers` | Array | 天数与多数户不同的户;一致时为 `[]` | | `dayCountOutliers[].orderId` | String | 子订单 ID(雪花) | | `dayCountOutliers[].orderNo` | String | 子订单编号 | | `dayCountOutliers[].contactName` | String | 联系人 | | `dayCountOutliers[].dayCount` | Integer | 该户的行程天数(该户完全没有行程时为 `0`) | | `days` | Array | 逐日汇总,按 `dayNumber` 升序 | | `days[].dayNumber` | Integer | 第几天,从 1 起 | | `days[].dayDate` | String | `yyyy-MM-dd`;各户不一致时取多数户的值 | | `days[].dayTitle` | String | 该天标题;取多数户的值 | | `days[].dayTotalAmount` | Number | **本日合计——只含 `ALL_SAME` 项** | | `days[].excludedNodeCount` | Integer | 未计入本日合计的项数 | | `days[].nodes` | Array | 该天的行程项 | | `days[].nodes[].nodeKey` | String | 归组键,下钻接口原样回传 | | `days[].nodes[].nodeName` | String | 行程项名称 | | `days[].nodes[].nodeType` | String | 行程项类型(`SCENIC` / `RESTAURANT` / `ACTIVITY` / `SERVICE` / `CUSTOM`) | | `days[].nodes[].resourceType` | String | 资源类型(`SCENIC_SPOT` / `RESTAURANT` / `ACTIVITY`;自定义项可能为 `null`) | | `days[].nodes[].resourceId` | String | 资源 ID(雪花);自定义项为 `null` | | `days[].nodes[].resourceName` | String | 资源名称快照 | | `days[].nodes[].startTime` | String | 精确时间 `HH:mm`;各户不一致时取多数户的值 | | `days[].nodes[].timePeriod` | String | 时段说明(上午 / 下午 / 全天);取多数户的值 | | `days[].nodes[].consistency` | String | `ALL_SAME` / `VALUE_DIFF` / `PARTIAL` | | `days[].nodes[].householdCount` | Integer | 有该项的户数 N | | `days[].nodes[].totalHouseholds` | Integer | 参与统计的总户数 M | | `days[].nodes[].unitPrice` | Number | 单价;**仅 `ALL_SAME` 有值**,否则 `null` | | `days[].nodes[].unitPriceMin` / `unitPriceMax` | Number | 单价区间;**仅非 `ALL_SAME` 有值** | | `days[].nodes[].quantity` | Integer | 数量;仅 `ALL_SAME` 有值 | | `days[].nodes[].quantityMin` / `quantityMax` | Integer | 数量区间;仅非 `ALL_SAME` 有值 | | `days[].nodes[].totalAmount` | Number | 小计;仅 `ALL_SAME` 有值 | | `days[].nodes[].countedInDayTotal` | Boolean | 是否计入本日合计——**只有 `ALL_SAME` 才是 `true`** | #### 请求示例 ```http GET /v3/admin/order/group-batch/2097498512387104770/itinerary Authorization: Bearer ``` #### 响应示例 TEST 实测(3 户团,节选第 1 天的两项 —— 一项全团一致、一项只有一户有): ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2097498512387104770", "totalHouseholds": 3, "dayCountConsistent": false, "dayCount": 3, "dayCountOutliers": [ { "orderId": "2097498673159028737", "orderNo": "HL20260909093347891", "contactName": "容量验收客3", "dayCount": 4 } ], "days": [ { "dayNumber": 1, "dayDate": "2026-11-20", "dayTitle": "抵达与草原初见", "dayTotalAmount": 545.00, "excludedNodeCount": 2, "nodes": [ { "nodeKey": "e735c79b9a59a955", "nodeName": "中俄边境公路(卡线)", "nodeType": "SCENIC", "resourceType": "SCENIC_SPOT", "resourceId": "3001000000000000019", "resourceName": "中俄边境公路(卡线)", "startTime": null, "timePeriod": "MORNING", "consistency": "ALL_SAME", "householdCount": 3, "totalHouseholds": 3, "unitPrice": 80.00, "unitPriceMin": null, "unitPriceMax": null, "quantity": 1, "quantityMin": null, "quantityMax": null, "totalAmount": 80.00, "countedInDayTotal": true }, { "nodeKey": "3f2d83708b6957db", "nodeName": "驯鹿拉车体验", "nodeType": "ACTIVITY", "resourceType": "ACTIVITY", "resourceId": "2029926122787946497", "resourceName": "驯鹿拉车体验", "startTime": null, "timePeriod": "MORNING", "consistency": "VALUE_DIFF", "householdCount": 3, "totalHouseholds": 3, "unitPrice": null, "unitPriceMin": 60.00, "unitPriceMax": 90.00, "quantity": null, "quantityMin": 1, "quantityMax": 1, "totalAmount": null, "countedInDayTotal": false } ] } ] } } ``` #### 空数据 / 降级响应 团期存在但**没有活跃子订单**(或活跃户都没有行程)时回空天数组,不报错: ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2089667627927437313", "totalHouseholds": 0, "dayCountConsistent": true, "dayCount": 0, "dayCountOutliers": [], "days": [] } } ``` 单项降级:`total_amount`(订单侧的成交小计,签单 / 退款口径下才冻结)为空时, 小计按 `unitPrice × quantity` 现算;两个都缺(如无价的「早餐」)时 `totalAmount` 回 `null`, **不当 0 计入本日合计**。`startTime` / `timePeriod` / `resourceId` 为空的项照常返回。 #### 错误响应 团期不存在(**与团期详情同码,不是回空数组**): ```json { "code": 589500, "message": "团期不存在", "data": null, "success": false } ``` 当前角色没有 `group-batch:view` 权限: ```json { "code": 589507, "message": "无操作权限(非团期管理员 / 非本定制师名下)", "data": null, "success": false } ``` 缺少或无效的 token 由网关拦截,返回 `401`。本端点不产生其它业务错误码。 #### 业务边界 - 只读端点,不改任何数据;**团期层没有行程写口**。 - 一张表都不建、不给行程表加团期列——实时汇总在团活跃子订单的行程。 - 活跃集与团期其余聚合同口径:已取消 / 已退单 / 已软删户不出现,**也不计入分母 M**。 - 返回全量,不分页。 - `dayDate` / `dayTitle` / `startTime` / `timePeriod` 各户不一致时取**多数户**的值。 - `dayCount` 取多数户的天数;与多数不同的户进 `dayCountOutliers`(完全无行程的户以 `dayCount=0` 出现)。 - 本日合计只含 `ALL_SAME` 项;`80` 与 `80.00` 判为同一个价(按数值比,不比标度)。 - 权限码 `group-batch:view`,与团期看板、六芯片明细、时间线一致。 ### 2. 团期行程某项逐户下钻 `GET /v3/admin/order/group-batch/:groupBatchId/itinerary/nodes/:nodeKey` **VO**: `GroupBatchItineraryNodeDetailVO` #### 使用场景 汇总页看到某项标着「2/3 户」或「¥80~150」时点进来,看清楚**是哪几户不一样、各自多少**。 每一行都带 `orderId`,据此跳到那户的订单行程页去改——团期层没有任何行程编辑入口。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `groupBatchId` | path | Long | 是 | 雪花 ID,按字符串传 | 团期 ID | | `nodeKey` | path | String | 是 | 原样取自汇总响应 | 归组键 | | `dayNumber` | query | Integer | 否 | 从 1 起 | 只看第几天;不传 = 跨天全看 | 无 body。 #### 出参 `Result`。 | 字段 | 类型 | 说明 | |---|---|---| | `groupBatchId` | String | 团期 ID(雪花) | | `nodeKey` | String | 原样回显请求里的归组键 | | `dayNumber` | Integer | 原样回显请求里的天;未传时 `null` | | `nodeName` | String | 行程项名称;无任何户有该项时 `null` | | `consistency` | String | `ALL_SAME` / `VALUE_DIFF` / `PARTIAL`,与汇总同口径 | | `householdCount` | Integer | 有该项的户数 N | | `totalHouseholds` | Integer | 参与统计的总户数 M | | `items` | Array | 逐户明细,**含没有该项的户** | | `items[].orderId` | String | 子订单 ID(雪花)——前端据此跳转到该户订单行程 | | `items[].orderNo` | String | 子订单编号 | | `items[].contactName` | String | 联系人 | | `items[].has` | Boolean | 该户有没有这一项 | | `items[].dayNumber` | Integer | 第几天;`has=false` 时 `null` | | `items[].unitPrice` | Number | 单价;`has=false` 时 `null` | | `items[].quantity` | Integer | 数量;`has=false` 时 `null` | | `items[].totalAmount` | Number | 小计;`has=false` 时 `null` | | `items[].startTime` | String | 精确时间 `HH:mm` | | `items[].timePeriod` | String | 时段说明 | #### 请求示例 ```http GET /v3/admin/order/group-batch/2097498512387104770/itinerary/nodes/3f2d83708b6957db?dayNumber=1 Authorization: Bearer ``` #### 响应示例 TEST 实测(该项三户都有,其中一户把单价从 60 改成了 90): ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2097498512387104770", "nodeKey": "3f2d83708b6957db", "dayNumber": 1, "nodeName": "驯鹿拉车体验", "consistency": "VALUE_DIFF", "householdCount": 3, "totalHouseholds": 3, "items": [ { "orderId": "2097498512223526914", "orderNo": "HL20260909093309393", "contactName": "容量验收客", "has": true, "dayNumber": 1, "unitPrice": 60.00, "quantity": 1, "totalAmount": 60.00, "startTime": null, "timePeriod": "MORNING" }, { "orderId": "2097498671368003585", "orderNo": "HL20260909093347283", "contactName": "容量验收客2", "has": true, "dayNumber": 1, "unitPrice": 90.00, "quantity": 1, "totalAmount": 90.00, "startTime": null, "timePeriod": "MORNING" }, { "orderId": "2097498673159028737", "orderNo": "HL20260909093347891", "contactName": "容量验收客3", "has": true, "dayNumber": 1, "unitPrice": 60.00, "quantity": 1, "totalAmount": 60.00, "startTime": null, "timePeriod": "MORNING" } ] } } ``` #### 空数据 / 降级响应 `nodeKey` 在本团期没有任何户命中(包括拼错的键、带了 `dayNumber` 但那天没有该项)时, 返回**零命中的完整户清单**而不是错误——前端拿到的仍是「谁有谁没有」这张表: ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2097498512387104770", "nodeKey": "deadbeefdeadbeef", "dayNumber": null, "nodeName": null, "consistency": "PARTIAL", "householdCount": 0, "totalHouseholds": 3, "items": [ { "orderId": "2097498512223526914", "orderNo": "HL20260909093309393", "contactName": "容量验收客", "has": false, "dayNumber": null, "unitPrice": null, "quantity": null, "totalAmount": null, "startTime": null, "timePeriod": null } ] } } ``` 团期无活跃子订单时 `items` 为 `[]`、`totalHouseholds=0`,同样不报错。 `has=false` 的行除身份字段外全部为 `null`,前端按「未参加」渲染即可。 #### 错误响应 团期不存在: ```json { "code": 589500, "message": "团期不存在", "data": null, "success": false } ``` 当前角色没有 `group-batch:view` 权限: ```json { "code": 589507, "message": "无操作权限(非团期管理员 / 非本定制师名下)", "data": null, "success": false } ``` 缺少或无效的 token 由网关拦截,返回 `401`。**未知 `nodeKey` 不是错误**,见上一节。 #### 业务边界 - 只读端点;`items` 里每行的 `orderId` 就是「去哪改」的跳转依据。 - `items` **含没有该项的户**(`has=false`),顺序与汇总接口的户序一致(按 `orderId` 升序)。 - 不带 `dayNumber` 时跨天返回该项的全部出现,同一户可能多行;带 `dayNumber` 则只看那一天。 - `consistency` 与汇总同口径,但取值集合随 `dayNumber` 收窄而变——只看第 1 天可能 `ALL_SAME`,跨天看可能 `VALUE_DIFF`。 - 同一户同一天重复出现同一项时只取第一条,保证 `householdCount <= totalHouseholds`。 - 已取消 / 已退单 / 已软删户既不出现在 `items` 里,也不计入 `totalHouseholds`。 - 权限码 `group-batch:view`。 ## 四、契约约束与正确调用方式 1. **`nodeKey` 只能原样回传,不要自己拼。** 它是 `(nodeName, resourceType, resourceId)` 的 稳定哈希(SHA-256 前 8 字节十六进制),服务端实时算、**不落库**。同一三元组在任何时候、 任何请求里都得到同一个键;但它是服务端实现细节,前端从汇总响应里取到就行。 2. **点哪张卡就把那张卡的 `dayNumber` 带上。** `nodeKey` **不含天**——同一个景区在第 1 天和 第 2 天各排一次是很常见的。不带 `dayNumber` 时下钻会跨天返回该项的全部出现, 同一户可能出现多行(各行带自己的 `dayNumber`),与你点的那张卡就对不上了。 3. **本日合计 `dayTotalAmount` 只含 `consistency=ALL_SAME` 的项。** 每项有 `countedInDayTotal` 标明是否计入,天级有 `excludedNodeCount` 给出被排除的项数—— 请在界面上明确提示,否则运营会把这个数当成「整团实际金额」。整团实际金额是核团的事。 4. **`ALL_SAME` 与非 `ALL_SAME` 取值字段不同**:`ALL_SAME` 用 `unitPrice` / `quantity` / `totalAmount`(此时区间字段为 `null`);`VALUE_DIFF` / `PARTIAL` 用 `unitPriceMin`~`unitPriceMax` / `quantityMin`~`quantityMax`(此时确定值字段为 `null`)。 别只读一边。 5. **团期层没有任何行程写口。** 要改行程,用下钻每行带的 `orderId` 跳到那户的订单行程页去改。 6. **雪花 ID 一律按字符串处理**(`groupBatchId` / `orderId` / `resourceId` 都以字符串下发)。 7. 权限码 `group-batch:view`,与团期看板、六芯片明细、时间线同码。 ## 六、边界行为 | 场景 | 行为 | |---|---| | 团期不存在 | `589500`(与团期详情同码,非空数组——这两个端点要先确认团期存在) | | 团期存在但无活跃子订单 | `code=200`,`days=[]`、`totalHouseholds=0`、`dayCount=0`、`dayCountConsistent=true` | | 有活跃户但都没有行程 | 同上,`days=[]`,不报错 | | 某户没有行程 | 该户不参与逐项统计,但**计入分母 M**,且在 `dayCountOutliers` 里以 `dayCount=0` 出现 | | 已取消 / 已退单 / 已软删户 | 不出现在汇总与下钻中,也**不计入分母 M** | | 不存在的 `nodeKey` | `code=200`,`householdCount=0`、`nodeName=null`、`consistency=PARTIAL`,`items` 里每户 `has=false`,**不报错** | | `dayNumber` 传了但该天没有该项 | 同上:零命中的空清单,不报错 | | 各户天数不同 | `dayCountConsistent=false`,`dayCount` 取**多数户**的天数,`dayCountOutliers` 列出与多数不同的户 | | 同一户同一天重复出现同一项 | 只取第一条;不替订单侧去重,也不因此把该户算成两户(保证 `householdCount <= totalHouseholds`) | | `80` 与 `80.00` | 判为**同一个价**(按数值比较,不比标度),不会误报成 `VALUE_DIFF` | | 单价 / 数量为 `null` | 参与一致性判定(都为 `null` 算一致);区间取值时 `null` 不参与 min/max | | 无 `group-batch:view` 权限 | `589507` | ## 七、不影响范围 - 不改任何既有接口的路径、入参、出参、错误码。 - 不改订单侧行程的任何读写口。 - **无表变更、无 Flyway、无数据迁移、无新增错误码。** - 网关路由不新增(`/v3/admin/**` 已覆盖)。 - 只滚 `hl-order-service-v3` 一个服务。 ## 八、测试环境已验证 TEST(`api.test.1814.love`)真实网关逐条取证。主用团期 `2097498512387104770`(3 户 · 3 天 · 真实产品展开), 另用 `2096412454643802114`(**55 户**)压 N+1、`2089667627927437313`(0 户)压空态。 差异场景由 SQL 造数,**已全部回滚**(回滚后复测:3 户 / 3 天 / 全一致 / 第 1 天合计 `605.00` / `excludedNodeCount=0`,与造数前逐字段一致)。 | 验收项 | 实测 | |---|---| | 按天返回全团行程,逐项含名称 / 时间 / 单价 × 数量 = 小计 / 本日合计 | ✅ 3 天,合计 `605.00` / `385.00` / `730.01` | | 各户完全一致时全为全团项,本日合计 = 单户本日合计 | ✅ 25 项全 `ALL_SAME`;第 1 天 30+80+60+35+60+160+100+80 = `605.00` | | 有户改价 → 该项返回区间,下钻看到每户各自的价 | ✅ `VALUE_DIFF`,`unitPriceMin/Max = 60/90`;下钻三行 60 / 90 / 60 | | 有户加项 → 标 N/M,下钻列出是哪几户加的 | ✅ `PARTIAL` `1/3`,下钻 `has=false/false/true` | | 本日合计只含全团一致项,且标出未计入的项 | ✅ 合计由 `605.00` 降为 `545.00`,`excludedNodeCount=2`,两项 `countedInDayTotal=false` | | 天数不一致 → 预警 + 列出是哪几户 | ✅ `dayCountConsistent=false`,`dayCount=3`,`dayCountOutliers` 指名到 `HL20260909093347891`(4 天) | | 下钻每行带 `orderId` 供跳转;团期层无写口 | ✅ 每行均带;本次只新增两个 GET | | `nodeKey` 稳定、不落库 | ✅ 两次请求 25 个 `nodeKey` 完全一致;库中无该列 | | 已取消户不出现在汇总与下钻,也不计入分母 | ✅ 置 `CANCELLED` 后分母 3→2,其加项与多出的第 4 天一并消失,天数预警自动解除 | | 空态与未知 `nodeKey` | ✅ 0 户团回 `days: []`;未知键回零命中清单;团期不存在回 `589500` | | 权限 | ✅ `CUSTOMIZER` 角色两个端点均 `589507`;无 token 由网关拦 `401` | | 查询次数与户数无关(禁 N+1) | ✅ `performance_schema` 计数:3 户与 55 户**均恰好 2 条** `order_itinerary_*` 查询;55 户耗时 88ms | **跨天项实测**(`dayNumber` 存在的理由):「巴尔虎蒙古部落」在第 1、2 天各排一次—— 不带 `dayNumber` 下钻回 6 行(3 户 × 2 天,各带自己的 `dayNumber`),带 `dayNumber=1` 回 3 行,与第 1 天那张卡一致。 **小计兜底实测**:该团 `total_amount` 全为空、`unit_price` 有值。兜底前本日合计恒为 `0`, 兜底后 `605.00`;无价的「早餐」小计仍为 `null`,不当 0 计入。 ## 十、相关文档 - 实施单编号:GB-ADM-018 / GB-ADM-019(新增),实施单 `docs/group/实施单/04-团期行程安排.html` - 需求:《团期模块详细设计 v3.0》§3.11「只读汇总、不建表」;SRS §0.13;《数据模型》§A.9D「归组键不落库」 - ⚠️ **前端形态无原型可依**:《需求逐屏对照》的团期详情 12 屏里没有「行程」屏,本需求是 2026-08-31 jw 口述新增,契约与验收要点齐备但没有画面。界面长什么样需 jw 与 mmg 另定。 ## 关联 / 联系人 - 工单:https://git.1814.love:8443/wx/HL/issues/7378 - PR:https://git.1814.love:8443/wx/HL/pulls/7397 - 后端:jw;前端:mmg