docs(changelog): order-v3 团期详情返回整团待收 unpaidAmount(#8045)
changelog-filename-gate / validate (push) Failing after 2s

团期详情端点(A2)新增响应字段 unpaidAmount,值 = max(0, receivableAmount − receivedAmount),
恒非 null 恒非负、字符串型金额;列表页 A1 早已有同名字段,本次把详情页补齐。

给前端(mmg)的三条要点:
1. 不要再自己拿应收减已收,直接读 unpaidAmount;
2. 不要与本页子订单项的 balanceAmount 混用(那是 per-order「应收 − 已退 − 已付」,
   扣退款且取消单归 0,同名不同义);
3. 有退款的团本字段按毛已付算会偏大,权威待收是财务 tab 的 items/totals ——
   ⚠️ 注意是「逐户明细与合计」,不是财务 tab 的顶层 unpaidAmount
   (顶层与本字段同源同公式,数值一致;实测差异见正文第八节)。

既有四个金额字段(receivableAmount / receivedAmount / totalReceivable / totalReceived)
的值、名称、JSON 形态逐字未变,纯增量。

Issue: #8045   PR: #8101

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
这个提交包含在:
jw
2026-09-21 14:59:57 +08:00
共同撰写人 Claude Opus 4.8
父节点 b29a9f72cb
当前提交 e8d53378c7
@@ -0,0 +1,321 @@
---
schema: "hl-changelog/v2"
ticket: "8045"
title: "团期详情返回整团待收 unpaidAmount"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
verified_at: "2026-09-21"
status_note: "团期详情端点(A2)新增响应字段 unpaidAmount(整团待收),值 = max(0, receivableAmount − receivedAmount),恒非 null 恒非负,字符串型金额。此前该端点已同时返回 receivableAmount 与 receivedAmount,但没有待收字段,前端只能自己做减法——而本页子订单项的 balanceAmount 是另一套算法(扣退款、取消单归 0),前端自算会在同一屏里产生第二套口径,故由后端给出。列表页(A1)早已有同名字段,本次是把详情页补齐,两处同源同公式。⚠️ 两点必须读:① 本字段按「毛累计已付」算,发生过退款的团偏小;逐户扣退款的权威待收在财务 tab 的 items/totals,不是财务 tab 的顶层 unpaidAmount(顶层与本字段同源同公式,数值一致)——同页展示两者时请在 UI 上区分标注(实测见「八」)。② 不要把它与子订单项的 balanceAmount 混用。本页既有四个金额字段(receivableAmount / receivedAmount / totalReceivable / totalReceived)的值、名称、形态逐字未变,纯增量。"
updated_at: "2026-09-21"
base: "dev-v3"
---
# order-v3: 团期详情返回整团待收 unpaidAmount
> **服务**: hl-order-service-v3 (端口 8086)
> **PR**: [#8101](https://git.1814.love:8443/wx/HL/pulls/8101)
> **Issue**: [#8045](https://git.1814.love:8443/wx/HL/issues/8045)
> **日期**: 2026-09-21
> **影响范围**: 管理后台「团期详情」页顶部金额区(列表点团期进去的那一页)
---
## ⚠️ 关键变化
- **纯增量**:既有字段一个都没改,只多返回一个 `unpaidAmount`。不接入的前端不受影响。
- **不要再自己拿 `receivableAmount − receivedAmount`** —— 后端已给出这个值,且做了解析保护(负数不透出)。
- **不要与本页子订单项的 `balanceAmount` 混用**:`balanceAmount` 是 per-order「应收 − 已退 − 已付」(扣退款、取消单归 0),与本字段**同名不同义**。#7066 曾专门花一节向前端解释过这个坑,这里再强调一次。
- **🔴 有退款的团,本字段会偏大**:本字段用的是「毛累计已付」(退款不回减),财务 tab 的**逐户明细与合计**(`items[].unpaidAmount` / `totals.unpaidAmount`)是逐户扣退款的。**注意是「明细与合计」,不是财务 tab 的顶层 `unpaidAmount`** —— 财务 tab 顶层的 `unpaidAmount` 与本字段同源同公式,数值一致(实测见第八节)。若把本字段与财务 tab 的**合计**放在同一屏,两者会差一个「累计已退」的量,请在 UI 上区分标注,权威待收以财务 tab 的逐户明细/合计为准。
---
## 一、背景
`#7535` 那一批统一了团期金额字段命名,同时给**列表**页(A1 分页项 `GroupBatchPageItemRespVO`)加了 `unpaidAmount`,但对**详情**页(A2)只做了 `totalReceivable/totalReceived` → `receivableAmount/receivedAmount` 的命名统一,**漏了待收字段**。
于是出现「列表页有待收、点进详情页反而没有」的观感缺口:运营要一眼看到「这个团还差多少钱没收」,只能心算,或退回列表页看。
需求来源:前端 2026-09-20《前端待后端交付清单》第 3 项,原文标注「无工单,待排期」;本单为该事项排期。前端明确拒绝自算,理由是「口径会漂移」——这个担心成立,见上一节。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期详情(A2) | GET | `/v3/admin/order/group-batch/:id` | 修改 | 响应新增 `unpaidAmount`(整团待收) |
> 财务 tab 端点 `GET /v3/admin/order/group-batch/:id/finance` **本次一行未改**,列在本节仅为对照口径。
---
## 三、接口详情
### 1. 团期详情 `GET /v3/admin/order/group-batch/:id`
**VO**: `Result<GroupBatchDetailRespVO>`
#### 使用场景
管理后台「团期详情」页主数据。进入该页时调用一次,页面顶部的整团应收 / 已收 / **待收** 三格都读本响应。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | String(雪花 ID) | ✅ | 团期 ID | 不存在或已软删返回 589500 |
#### 出参 `Result<GroupBatchDetailRespVO>`
本次**只新增 1 个字段**,其余字段一律不动、不改名、不改类型。
| 字段 | 类型 | 说明 |
|------|------|------|
| receivableAmount | String(金额) | 整团应收(Σ 在团子订单应付总额)。**不变** |
| receivedAmount | String(金额) | 整团已收(Σ 毛累计已付,退款不回减)。**不变** |
| **unpaidAmount** | String(金额) | **【新增】** 整团待收 = `max(0, receivableAmount − receivedAmount)`。恒非 null、恒 ≥ 0 |
| totalReceivable | String(金额) | 已废弃,与 `receivableAmount` 逐字同值。**不变**(只标记不删) |
| totalReceived | String(金额) | 已废弃,与 `receivedAmount` 逐字同值。**不变**(只标记不删) |
金额一律是**字符串**(如 `"12000.00"`),不是 JSON 数字,**不要当数字解析**。
#### 请求示例
```http
GET /v3/admin/order/group-batch/2101908228566908930
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"groupBatchId": "2101908228566908930",
"receivableAmount": "4000.00",
"receivedAmount": "2000.00",
"unpaidAmount": "2000.00",
"totalReceivable": "4000.00",
"totalReceived": "2000.00"
},
"success": true
}
```
(仅列相关字段,其余字段保持原样。)
#### 空数据 / 降级响应
无在团子订单的空团,三个金额**同为 `"0"`**(注意不是 `"0.00"`,与列表页 A1 的行为一致,前端自行格式化):
```json
{
"code": 200,
"data": {
"receivableAmount": "0",
"receivedAmount": "0",
"unpaidAmount": "0"
},
"success": true
}
```
#### 错误响应
团期不存在或已软删:
```json
{
"code": 589500,
"message": "团期不存在",
"data": null,
"success": false
}
```
无查看权:
```json
{
"code": 589507,
"message": "无权限访问该团期",
"data": null,
"success": false
}
```
| 码 | 触发 |
|---|---|
| 589500 | `groupBatchId` 不存在或已软删 |
| 589507 | 调用方无团期查看权;定制师读不属于自己的团期 |
鉴权失败在本项目是 **HTTP 200 + body `code=401`**,不要只看 HTTP 状态码。
#### 业务边界
- **本字段是纯内存计算**,不新增查询、不新增 Feign 调用;响应变慢与它无关。
- **不是权限变更**:A2 走一般查看权(`PERMISSION_VIEW`),本字段不扩大任何信息暴露面——它是由同一响应里**已经返回**的 `receivableAmount` 与 `receivedAmount` 相减得到的,信息增量为零。
- **口径**:`receivedAmount` 是毛累计已付、**退款不回减**,所以发生过退款的团本字段**偏小**(应收没变、已付也没被退款冲减,差额仍然是「待收」)。
- **标度**:沿用同一行 `receivableAmount` / `receivedAmount` 的原始标度,不做二次收敛。同一行三个金额标度一致。
- **负值保护**:已收多于应收(退款 / 多收留下的历史脏数据)时返回 `"0"`,**不会出现负号**。
---
## 四、契约约束与正确调用方式
- **不要自己算**:直接读 `unpaidAmount`,不要写 `receivableAmount - receivedAmount`。
- **不要用 `balanceAmount` 顶替**:那是子订单项的 per-order 口径(应收 − 已退 − 已付,取消单归 0),与本字段**同名不同义**。
- **财务 tab 的对照口径要说清是哪一个**:
- 财务 tab **顶层** `unpaidAmount` —— 与本字段**同源同公式**,数值一致(**不是**逐户扣退款的)。
- 财务 tab `items[].unpaidAmount` / `totals.unpaidAmount` —— **逐户扣退款**的权威口径。有退款的团,它与本字段会差一个「累计已退」。
- 三个金额都是字符串,`"0"` 与 `"0.00"` 都可能出现(空团是前者),比较时请按数值比较而不是字符串相等。
---
## 六、边界行为
| 场景 | 行为 |
|---|---|
| 正常有在团子订单 | `unpaidAmount = 应收 − 已收`(两位小数) |
| 无在团子订单(空团) | 三个金额同为 `"0"`,非 null |
| 已收 > 应收(多收 / 退款脏数据) | `unpaidAmount` 返回 `"0"`,不透负数。**TEST 实测见第八节** |
| 发生过退款的团 | 本字段按毛已付算,比财务 tab 的**逐户合计**偏大;与财务 tab 顶层一致 |
| 团期不存在 / 已软删 | 589500 |
| 无查看权 | HTTP 200 + body `code=589507` |
| 老前端不读新字段 | 纯增量,无影响 |
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `unpaidAmount` | **不存在**(响应体里没有这个 key) | `"12000.00"`(字符串型金额) |
| `receivableAmount` | `"48000.00"` | `"48000.00"`(**逐字未变**) |
| `receivedAmount` | `"36000.00"` | `"36000.00"`(**逐字未变**) |
| `totalReceivable` | `"48000.00"`(已废弃) | `"48000.00"`(**逐字未变**,仍只标记不删) |
| `totalReceived` | `"36000.00"`(已废弃) | `"36000.00"`(**逐字未变**) |
| 其余全部字段 | — | **一个都没动** |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 详情页「待收尾款」从哪来 | 前端自己拿 `receivableAmount − receivedAmount` 算 | 后端返回 `unpaidAmount`,前端直接读 |
| 已收大于应收时 | 前端自算会拿到负数,得自己处理 | 后端返回 `"0"`,不透负数 |
| HTTP 状态 / 错误码 / 判权 | — | **不变** |
| 响应耗时 | — | **不变**(新增字段是内存计算,不加查询、不加 Feign) |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否。纯增量字段,既有四个金额字段的值 / 名称 / 类型 / JSON 形态逐字未变(TEST 改前改后逐字对照见第八节)。
- **前端是否必须同步上线**: 否。不接入则详情页保持现状(没有待收那一格)。
- **前端 workaround 清理点**: 若前端此前在详情页**自己算过**待收(`receivableAmount - receivedAmount`),现在可以撤掉,改读 `unpaidAmount`。
- **回滚**: 回滚本 PR 即可,只读端点、无数据变更、无表结构变更。
---
## 七、不影响范围
- `GET /v3/admin/order/group-batch/:id/finance`(财务 tab)**一字未改**。
- `GET /v3/admin/order/group-batch`(A1 列表)**一字未改**,它的 `unpaidAmount` 早就存在。
- 子订单项 `balanceAmount`(`GroupBatchOrderItemRespVO`)**未动**。
- 判权、错误码、网关路由、事务与锁**均未变**。
- 其他微服务(hl-finance / hl-product-service-v2 / hl-user-service 等)**未动**。
- 无数据库表结构变更,无 Flyway 迁移。
---
## 八、测试环境已验证
TEST 环境(`hl-order-service-v3` + `hl-gateway` 均为 dev-v3 @ `c238f38c3`,`BEHIND 0/N`,`STATE ok`),2026-09-21,走真实网关 `https://api.test.1814.love:9443`。
**构建身份探针**(改前 / 改后同端点对照,确认跑的确实是本单代码):
```
改前(dev-v3 旧字节)GET /v3/admin/order/group-batch/2101908228566908930 → 响应体没有 unpaidAmount 这个 key ✓
改后(dev-v3 c238f38c3)同 URL → 出现 "unpaidAmount": "2000.00" ✓
```
```
AC-1 金额正确 + 形态:团 2101908228566908930
receivableAmount "4000.00" / receivedAmount "2000.00" / unpaidAmount "2000.00"
手算 max(0, 4000.00 − 2000.00) = 2000.00,一致;三个都是 JSON 字符串 ✓
团 2101906599658618882:应收 "6000.00" / 已收 "0.00" / 待收 "6000.00" ✓
AC-2 已收 > 应收边界(自造 fixture,见「造数清单」):
应收 "2000.00" / 已收 "3000.00" → unpaidAmount = "0",无负号,非 null ✓
AC-3 空团:团 2101250106387726338 无在团子订单
receivableAmount "0" / receivedAmount "0" / unpaidAmount "0"(同值同形态) ✓
AC-4 纯增量:四个既有字段改前 → 改后逐字一致(4 个团全部比对,含空团与有退款的团) ✓
AC-5 注解四层文案(公式 / null 按 0 参与 / 退款不回减 / 权威口径指向财务 tab),
并由新增的单测钉住,删掉任一层即红 ✓
AC-6 「应收减已收」仍只有一处实现:新增行不含 `.subtract(`;实参调既有 calcUnpaid ✓
AC-7 calcUnpaid 的「只此一处减法」javadoc 已列入 toDetailVO 这条新调用方 ✓
AC-8 精确测试:GroupBatchConverterTest 108 + GroupBatchAliasFieldSerializationTest 7
+ GroupBatchQueryControllerTest 9 = 124 跑 / 0 失败 / 0 错误 ✓
变异证明:注释掉 toDetailVO 那一行后恰好这 4 个转换器用例红(108 跑 / 4 失败),
控制器与序列化类保持绿 —— 新断言不是恒真 ✓
AC-9 MapperBoundaryArchTest 门禁:本单零新增违规,门禁红系既有基线
(干净的 c62907410 上同样 27 跑 / 1 失败,9 处违规全在 payment → refund.mapper,
见 [#7989](https://git.1814.love:8443/wx/HL/issues/7989),P1,yst 属主)
AC-10 网关实测(上方 URL 与 deploy-status 两行) ✓
AC-11 有退款的团与财务 tab 的口径对照,见下 ✓
```
**AC-11 口径对照**(团 `2099459274966016001`,子订单 `HL20260914192431793` 有一笔 100.00 的**成功退款**):
| 取值处 | 数值 | 是否扣退款 |
|---|---|---|
| **本单新增的 A2 `unpaidAmount`** | `"1900.00"` | 否(毛已付) |
| 财务 tab **顶层** `unpaidAmount` | `"1900.00"` | 否(**与本字段同源同公式**) |
| 财务 tab `totals.unpaidAmount` | `"1800.00"` | **是**(逐户扣退款) |
| 财务 tab `items[0].unpaidAmount` | `"1800.00"` | **是** |
1900.00 − 1800.00 = **100.00 = 该团那笔退款金额**。即:**本字段与财务 tab 顶层一致,与财务 tab 的逐户合计差一个「累计已退」**——前端要对齐的权威口径是后者。
> 扫描口径补充:TEST 上 237 个团期,**顶层 `unpaidAmount` 与「应收 − 已收」无一不等**(两者同源);顶层与 `totals` 不等的恰好 1 个,就是上表这个有退款的团。
**送测数据(自造,请勿清理)**:
| groupBatchId | 名称 | 用途 | 现状 |
|---|---|---|---|
| `2101927290046943234` | `#8045-AC2多收边界` | AC-2「已收 > 应收」边界造数(其子订单 `2101927289925308418` 的 `paid_amount` 曾临时置 3000.00,**已还原为 0.00**) | 挂 1 个子订单(PENDING_PAY),班期出发日 2026-12-20 |
> 该班期挂着订单,删不掉(`BATCH_HAS_ENROLLED_ORDERS`),留着不干扰他人:未被可报名谓词 / 选品列表 / order-v3 下单闸采纳。
其他 AC 用的是 TEST 上**既有**真实团期,未改任何既有数据。
---
## 十、相关文档
- 工单 [#8045](https://git.1814.love:8443/wx/HL/issues/8045)
- [#7535](https://git.1814.love:8443/wx/HL/issues/7535) 团期金额字段命名统一(A1 加了 `unpaidAmount`,A2 漏了,本单补齐)
- [#7066](https://git.1814.love:8443/wx/HL/issues/7066) 解释过「两个 `balanceAmount` 同名不同义」的那个坑
- [#7989](https://git.1814.love:8443/wx/HL/issues/7989) order-v3 ArchTest 门禁既有基线红(与本单无关,登记在案)
---
## 关联 / 联系人
### 链接
- **Issue**: [#8045](https://git.1814.love:8443/wx/HL/issues/8045)
- **PR**: [#8101](https://git.1814.love:8443/wx/HL/pulls/8101)
### 联系人
- **后端负责人**: @jw
- **前端负责人**: @mmg