From a19ef0253305a963721c0a0a91b15a7d59f57f55 Mon Sep 17 00:00:00 2001 From: jw Date: Mon, 14 Sep 2026 15:09:45 +0800 Subject: [PATCH] =?UTF-8?q?changelog(7533):=20=E5=9B=A2=E7=BA=A7=E8=A1=8C?= =?UTF-8?q?=E7=A8=8B=E5=8D=95=E4=B8=8E=E5=9B=A2=E6=9C=9F=E7=AD=BE=E5=8D=95?= =?UTF-8?q?=E5=87=AD=E8=AF=81=EF=BC=88=E6=96=B0=E5=A2=9E=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个只读端点:print-itinerary(只出 ALL_SAME 项、差异项标「按户另见」) 与 sign-voucher(按供应商聚合、默认脱敏)。backend=deployed / gateway=verified。 Co-Authored-By: Claude Opus 5 (1M context) --- ...级行程单与团期签单凭证-新增接口-管理后台.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 changelogs-v2/2026-09/14_7533_团级行程单与团期签单凭证-新增接口-管理后台.md diff --git a/changelogs-v2/2026-09/14_7533_团级行程单与团期签单凭证-新增接口-管理后台.md b/changelogs-v2/2026-09/14_7533_团级行程单与团期签单凭证-新增接口-管理后台.md new file mode 100644 index 00000000..5499d577 --- /dev/null +++ b/changelogs-v2/2026-09/14_7533_团级行程单与团期签单凭证-新增接口-管理后台.md @@ -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` 承载订单归属),住宿读取覆盖「新分房 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)