--- schema: "hl-changelog/v2" ticket: "7533" title: "团级行程单 + 团期签单凭证(只出 ALL_SAME 项,差异项标「按户另见」;签单按供应商聚合、默认脱敏)" consumer: "admin" author: "jw(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "f1319851" target_release: "" verified_at: "2026-09-14" status_note: "两个纯读端点,管理台新增「团级行程单」「团期签单凭证」两处打印/导出入口即可。行程单只排版全团一致(ALL_SAME)项,差异项渲染成一行「按户另见(m/n 户)」并可用 nodeKey 点回 A6 下钻;签单凭证默认脱敏(showAmount=false,金额全 null),带 showAmount=true 才出金额。金额与 orderId 均为字符串形态防精度丢失。零 DDL、零网关路由改动(/v3/admin/** 既有覆盖)。;前端已交付(f1319851):团期详情「更多操作」加团级行程单/团期签单凭证两打印入口,复用 PrintPreviewModal,差异项可点回 A6 下钻,签单默认脱敏勾选才出金额,checkpoint 13 项全绿" updated_at: "2026-09-14" base: "dev-v3" --- # order-v3: 团级行程单 + 团期签单凭证(管理台读端) **服务**: hl-order-service-v3 **PR**: #7684 **Issue**: #7533 --- ## ⚠️ 关键变化 🟢 **新增两个只读端点(不改任何既有接口,订单级 print-itinerary / sign-voucher 行为零回归)**: 1. **团级行程单** `GET .../print-itinerary`:调既有团级行程汇总(A5),按 `consistency == ALL_SAME` 拆分——一致项进 `days[].nodes[]` 正常排版,差异项(`VALUE_DIFF` / `PARTIAL`)降级为 `days[].differingNodes[]` 一行标记 `note="按户另见"` 并保留 `nodeKey` 可下钻。`dayTotalAmount` 直接取汇总值不重算。 2. **团期签单凭证** `GET .../sign-voucher`:按供应商聚合酒店 / 景点 / 游玩条目,同一 `hotelId` 整团只产一条 `entry`(`householdCount == coveredOrderNos.size()`)。默认 `showAmount=false` 脱敏(`totalAmount` / `unitPrice` / `amount` 全 `null`),`showAmount=true` 才出金额;`scope` 可限 `ALL/HOTEL/SCENIC/ACTIVITY`。 3. **判权与全部业务落在 `GroupBatchDocumentService`**(顶部第一条判 `group-batch:view`,顶层无 `@Transactional`);契约新增批量方法 `HouseAssignmentReadContract.listHotelAssignmentsByOrderIds`(`Map` 承载订单归属),住宿读取覆盖「新分房 active allocation + 旧户冻结名单」双源,避免双计与 N+1 放大。 **无 DDL、无网关路由改动、订单级两端点结构一字未改。** ## 一、背景 团期管理台需要「一次出全团一张行程单 / 一份供应商签单凭证」。既有 A5 团级行程汇总(`GroupBatchItineraryService.summary`)已把逐户事实压成 `consistency ∈ ALL_SAME / VALUE_DIFF / PARTIAL` 的展示摘要;wx 2026-09-11 定案「团级行程单只出 ALL_SAME 项、差异项标『按户另见』」。本单只做**文档层**:调既有汇总按定案筛选排版(一致性判定一行不改,仍在 `GroupBatchItineraryService`),签单侧按供应商归并逐户住宿 / 资源事实。住宿读取按 wx 定案「本单自行实现双源、不设 #7327 前置依赖」,为强制发现两边口径漂移设 AC-R4 对账。 ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 出具团级行程单 | GET | `/v3/admin/order/group-batch/:groupBatchId/print-itinerary` | 新增 | 只排版 ALL_SAME 项,差异项标「按户另见」并留 nodeKey 可下钻;dayTotalAmount 取汇总不重算 | | 2 | 出具团期签单凭证 | GET | `/v3/admin/order/group-batch/:groupBatchId/sign-voucher` | 新增 | 按供应商聚合酒店/景点/游玩;默认脱敏 showAmount=false;scope 可限 ALL/HOTEL/SCENIC/ACTIVITY | ## 三、接口详情 ### 1. 出具团级行程单 `GET /v3/admin/order/group-batch/:groupBatchId/print-itinerary` **VO**: `Result`(path: groupBatchId) #### 使用场景 团期管理台「打印团级行程单」入口。只排版全团一致的行程项,差异项不出明细、改出一行「按户另见(m/n 户)」标记,前端可用 `nodeKey` 点回 A6 逐户下钻(`GET .../itinerary/nodes/:nodeKey?dayNumber=N`)。沿用既有鉴权与网关路由(`/v3/admin/**` 已覆盖)。 #### 入参字段表 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | groupBatchId | path | string(Long) | 是 | 雪花 ID | 团期主订单 ID | #### 出参字段表 | 字段 | 类型 | 说明 | |---|---|---| | groupBatchId | string(Long) | 团期 ID | | agencyName | string | 出团旅行社名称 | | batchNo / batchName / productName | string | 期号 / 期名 / 产品名 | | departDate / endDate | string(date) | 出发 / 结束日期 | | totalHouseholds | int | 活跃(非 CANCELLED)子订单户数 | | totalPax / totalRooms | int | 全团人数 / 房数 | | dayCount | int | 行程天数 | | roster | array | 逐户名册(`orderId`/`orderNo`/`contactName`/人数/`roomCount`/`roomType`/`specialNeeds`;**不含手机号 / 证件号**) | | days | array | 逐日;每日含 `dayNumber`/`dayDate`/`dayTitle`/`dayTotalAmount`/`nodes[]`(ALL_SAME 项)/`differingNodes[]`(差异项标记) | | days[].nodes[] | array | 全团一致项:`nodeKey`/`nodeName`/`nodeType`/`resourceType`/`resourceName`/`unitPrice`/`quantity` 等 | | days[].differingNodes[] | array | 差异项:`nodeKey`/`nodeName`/`consistency`(VALUE_DIFF/PARTIAL)/`householdCount`/`totalHouseholds`/`note="按户另见"` | | hotels / amountNote / footerNote | array/string | 酒店汇总 / 金额口径说明 / 页脚 | #### 请求示例 ```http GET /v3/admin/order/group-batch/2099318712929566721/print-itinerary Authorization: Bearer ``` #### 响应示例 ```json { "code": 200, "msg": "成功", "data": { "groupBatchId": "2099318712929566721", "agencyName": "博华(厦门)国际旅行社有限公司", "batchNo": "Q202611202099318646026260481", "departDate": "2026-11-20", "endDate": "2026-11-22", "totalHouseholds": 2, "days": [ { "dayNumber": 1, "dayDate": "2026-11-20", "dayTotalAmount": "120.00", "nodes": [ { "nodeKey": "e2c860e2...", "nodeName": "早餐", "resourceType": "RESTAURANT" } ], "differingNodes": [ { "nodeKey": "5fd13fb8...", "nodeName": "礼仪接机", "consistency": "PARTIAL", "householdCount": 1, "totalHouseholds": 2, "note": "按户另见" } ] } ] } } ``` #### 空数据 / 降级响应 单户团(`totalHouseholds == 1`)所有项恒判 ALL_SAME、全进 `nodes[]`、`differingNodes[]` 为空、仍 200。差异项只在 `differingNodes[]` 出标记不丢失,逐户明细走 A6 下钻端点。 #### 错误响应 ```json { "code": 589590, "msg": "团期下没有活跃子订单,无法出具团级文档", "data": null } ``` 团期无活跃子订单 → **589590**(不返回空白文档);团期不存在 → 589500;无 `group-batch:view` → 589507。 #### 业务边界 - 一致性判定沿用 `GroupBatchItineraryService`(本单一行不改):`occurrences.size() < total → PARTIAL`;否则比 `unitPrice`/`quantity` 全等 → `ALL_SAME`,否则 `VALUE_DIFF`(**不比执行时间**)。 - `dayTotalAmount` 直接取汇总的「只累加 ALL_SAME 项」结果,本单**不重算**(与 A5 `.../itinerary` 同一天逐值相等)。 - 同一 `nodeKey` 可跨天出现,故 `nodes[]` 与 `differingNodes[]` 的不相交断言按**逐天**成立。 ### 2. 出具团期签单凭证 `GET /v3/admin/order/group-batch/:groupBatchId/sign-voucher` **VO**: `Result`(path: groupBatchId;query: showAmount / scope) #### 使用场景 团期管理台「打印供应商签单凭证」。按供应商聚合酒店 / 景点 / 游玩条目,供业务对供应商核对与签单。默认脱敏(不出金额),需出金额时显式传 `showAmount=true`。 #### 入参字段表 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | groupBatchId | path | string(Long) | 是 | 雪花 ID | 团期主订单 ID | | showAmount | query | boolean | 否 | 默认 `false` | false 时金额全脱敏为 null | | scope | query | string | 否 | 默认 `ALL`;枚举 `ALL/HOTEL/SCENIC/ACTIVITY` | 非法值抛 589591 | #### 出参字段表 | 字段 | 类型 | 说明 | |---|---|---| | amountVisible | boolean | 是否出金额(= showAmount) | | groupBatchId / batchNo / batchName / productName | string | 团期标识 | | entries | array | 供应商条目 | | entries[].type | string | `HOTEL` / `SCENIC` / `ACTIVITY` | | entries[].title / supplierName | string | 条目标题 / 供应商名 | | entries[].householdCount | int | 覆盖户数(同 hotelId 整团一条) | | entries[].coveredOrderNos | array | 覆盖订单号(`size() == householdCount`) | | entries[].settleType | string | 结算方式(签单 / 公司付款 等) | | entries[].items | array | `name`/`date`/`qty`/`unit`/`unitPrice`/`amount`/`remark`(`showAmount=false` 时 `unitPrice`/`amount` 为 null) | | entries[].totalAmount | string(decimal) | 条目合计(`showAmount=false` 时为 null;否则 == Σ items.amount) | #### 请求示例 ```http GET /v3/admin/order/group-batch/2099318712929566721/sign-voucher?showAmount=true&scope=HOTEL Authorization: Bearer ``` #### 响应示例 ```json { "code": 200, "msg": "成功", "data": { "amountVisible": true, "groupBatchId": "2099318712929566721", "entries": [ { "type": "HOTEL", "supplierName": "远景酒店", "householdCount": 1, "coveredOrderNos": ["HL20260914100559129"], "settleType": "签单", "items": [ { "name": "草原蒙古包", "date": "2026-11-20", "qty": 2, "unit": "间夜", "unitPrice": "380.00", "amount": "760.00" } ], "totalAmount": "1520.00" } ] } } ``` #### 空数据 / 降级响应 房务未配房的团期返 200 且 `entries` 中无 `HOTEL` 条目(不报错);`showAmount=false`(默认)时 `amountVisible=false`,`entries[].totalAmount` 与 `items[].unitPrice`/`amount` 全 `null`,其余字段照常。 #### 错误响应 ```json { "code": 589591, "msg": "团级签单凭证范围非法(须为 ALL / HOTEL / SCENIC / ACTIVITY)", "data": null } ``` `scope` 非法 → **589591**(在取数之前 fail-fast);团期无活跃户 → 589590;户数超单次出具上限(200 户)→ **589592**;团期不存在 → 589500;无权限 → 589507。 #### 业务边界 - 酒店按 `hotelId` 聚合,整团同一酒店只产一条 `entry`,`householdCount == coveredOrderNos.size() ==` 该酒店覆盖的活跃户数。 - 住宿逐户事实走批量契约 `listHotelAssignmentsByOrderIds`(`Map` 承载订单归属),**新户按 active allocation 的 `roomCount`、旧户沿用冻结名单**,取数不随户数放大(Service 层对契约只调一次)。CANCELLED 户不计入(避免双计)。 - 户数上限 200 为保守闸(56 户实测 print≈255ms / sign≈245ms,远 < 2s);超限抛 589592 分批出具。 ## 四、契约约束与正确调用方式 - 金额字段(`totalAmount`/`items.unitPrice`/`items.amount`/`dayTotalAmount`)均为 `BigDecimal` 序列化为**字符串**,勿按 number 解析;`orderId`/`groupBatchId` 亦为字符串形态防大整数精度丢失。 - 默认脱敏:不传 `showAmount` 即 `false`,金额全 null;出金额必须显式 `showAmount=true`。 - 差异项要下钻逐户明细,用 `differingNodes[].nodeKey` 调 A6 `GET .../itinerary/nodes/:nodeKey?dayNumber=N`。 ## 五、数据库行为 - 无表变更、无 Flyway、无新增列、无索引调整、无 H2 `*-schema.sql` 改动。 - 纯读:行程走 `GroupBatchItineraryService.summary`(既有);住宿走 `HouseAssignmentReadContract` 逐户读(新分房 allocation + 旧户冻结)。 ## 六、边界行为 - 判权 `group-batch:view` 是 Service 顶层第一条语句,命中即抛 589507、**取数之前**返回(判权先于取数)。 - 错误码段位 589500-589599(团期专属)。本单三码因 #7513 已抢占 589589 而顺延:589590 无活跃户 / 589591 scope 非法 / 589592 超上限。 - 批量住宿读循环复用逐单双源合流(逐单方法含 CONFIRMED 过滤 / 旧户跳过 / 团期分房行合流),Service 层对契约方法只调一次(不随户数放大)。 ## 七、不影响范围 - 订单级 `GET /v3/admin/order/:orderId/print-itinerary`、`GET /v3/admin/order/:orderId/sign-voucher` 结构与行为一字未改(`git diff origin/dev-v3...HEAD` 不含其 Service/VO)。 - 一致性判定(A5/A6 语义)本单一行不改;无网关路由改动、无 Feign/MQ 改动、无 DDL。 ## 八、测试环境已验证 2026-09-14 TEST 网关(自签 admin token,`https://api.test.1814.love:9443`)+ 真 MySQL 造数,工单 #7533 的 18 条 AC 中 17 条逐条通过(AC-R3 联合项本单侧已验、#7536 分页侧待其交付后重跑): - AC-1 造 VALUE_DIFF 团逐天 `nodes`/`differingNodes` 的 `nodeKey` 无交集且两类项并存;AC-2 `dayTotalAmount` 与 A5 逐值相等(Decimal);AC-3 VALUE_DIFF/PARTIAL 均入 `differingNodes` 且下钻端点返逐户明细。 - AC-4 `roster == totalHouseholds == 活跃子订单(DB)` 且无手机 / 证件字段;AC-5 脱敏两态(false 金额全 null / true 出 1520.00·380.00·760.00)。 - AC-6 多户同酒店团(3 活跃户)恰一条 HOTEL 条目、`householdCount=3==coveredOrderNos.size()==活跃户`;AC-8 `scope=XXX→589591`、零活跃团 `→589590`、耗时 56 户 print=255ms/sign=245ms,超上限 589592 单测覆盖。 - AC-10 单户团全进 `nodes`/无房团无 HOTEL 条目不报错;AC-R1 纯新团出酒店条目 + 混合团逐户房数/日期守恒;AC-R2 三团金额守恒 + 四场景(同资源跨天/部分户/多户/签单 vs 公司付款);AC-R4 双源对账:A 户新源 4 间夜与 `group_batch_room_allocation` 逐日精确相等、CANCELLED 户被正确排除、B 户 legacy 冻结。 - AC-7/9/11/12 单测取证;AC-13 全量 `mvn -o -pl hl-order-service-v3 -am test` — Tests run 10791 / 0 failure / 0 error / 7 skipped,含 RedLineArchTest/MapperBoundaryArchTest,BUILD SUCCESS;AC-14 deploy-status 归属 `feat/7533-... | 1594d95ed | 0/N | ok`。 ## 十、相关文档 - 工单 #7533 - 全过程记录:HL 仓 `dev-records/records/2026-09-14-local-7533-group-print-sign-voucher.md` ## 关联 / 联系人 - 后端:jw(已合入 dev-v3 + TEST 实测) - 前端:mmg(新增两个打印/导出入口;金额字符串形态、默认脱敏需传 showAmount=true 出金额;差异项 nodeKey 可下钻 A6)