changelog(7533): 团级行程单与团期签单凭证(新增接口,管理后台)
changelog-filename-gate / validate (push) Failing after 2s

两个只读端点:print-itinerary(只出 ALL_SAME 项、差异项标「按户另见」)
与 sign-voucher(按供应商聚合、默认脱敏)。backend=deployed / gateway=verified。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
jw
2026-09-14 15:09:45 +08:00
共同撰写人 Claude Opus 5
父节点 e4481081ee
当前提交 a19ef02533
@@ -0,0 +1,253 @@
---
schema: "hl-changelog/v2"
ticket: "7533"
title: "团级行程单 + 团期签单凭证(只出 ALL_SAME 项,差异项标「按户另见」;签单按供应商聚合、默认脱敏)"
consumer: "admin"
author: "jw(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: "#7533"
target_release: ""
verified_at: "2026-09-14"
status_note: "两个纯读端点,管理台新增「团级行程单」「团期签单凭证」两处打印/导出入口即可。行程单只排版全团一致(ALL_SAME)项,差异项渲染成一行「按户另见(m/n 户)」并可用 nodeKey 点回 A6 下钻;签单凭证默认脱敏(showAmount=false,金额全 null),带 showAmount=true 才出金额。金额与 orderId 均为字符串形态防精度丢失。零 DDL、零网关路由改动(/v3/admin/** 既有覆盖)。"
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<Long,List>` 承载订单归属),住宿读取覆盖「新分房 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<GroupPrintItineraryRespVO>`(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 <admin token>
```
#### 响应示例
```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<GroupSignVoucherRespVO>`(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 <admin token>
```
#### 响应示例
```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)