新增尾款收款模型重构专题(确认行程主报账人必填化 + 核单欠收硬闸豁免 + 财务应收台账,#8248)
changelog-filename-gate / validate (push) Failing after 2s

这个提交包含在:
yaosutu
2026-09-24 15:28:24 +08:00
父节点 92ce44f6b4
当前提交 31b3e68b7a
@@ -0,0 +1,453 @@
---
schema: "hl-changelog/v2"
ticket: "8248"
title: "尾款收款模型重构:确认行程主报账人必填化 + 核单欠收硬闸按主报账人豁免 + 财务应收台账"
consumer: "admin"
author: "yst"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-24"
status_note: "尾款收款模型重构 Epic(方案甲·全实时)。PR-6a #8274(581062) + PR-5a #8263(584082) + PR-3 #8207 合并 + PR-5b #8325/②#8336/③#8328 部署。部署:hl-order-service-v3 dev-v3 @ baf643412,2026-09-24 滚动部署(8086/8186 均 UP)。部署后 fin↔order 对账 D1~D7 现网零漂移。"
updated_at: "2026-09-24"
base: "dev-v3"
---
# 尾款收款模型重构:确认行程主报账人必填化 + 核单欠收硬闸豁免 + 财务应收台账
> **存放目录**: 二期(v3) → `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3(订单核心域 + 核单域)· hl-finance(收款域)
> **Issue**: https://git.1814.love/wx/HL/issues/8248 (主) · https://git.1814.love/wx/HL/issues/8265 · https://git.1814.love/wx/HL/issues/8191
> **PR**: https://git.1814.love/wx/HL/pulls/8274 · https://git.1814.love/wx/HL/pulls/8263 · https://git.1814.love/wx/HL/pulls/8207
> **日期**: 2026-09-24
> **影响范围**: 管理后台「订单确认行程弹框」「核单提交」「财务应收台账」三处
---
## ⚠️ 关键变化(前端必读)
- **确认行程前必须先有主报账人**:普通订单点「确认行程」时,如果本单还没有主报账人且弹框里也没选人,接口直接报 **581062「请先指定本单主报账人(尾款代收人)」**,确认动作不发生。确认弹框必须提供主报账人选择项并传 `reporterAssignmentId`。
- **核单欠收硬闸放行面扩大**:提交核单时「尾款未收齐」不再一律拦死——只要本单已指定主报账人,欠收部分记为主报账人代收口径,允许提交核单;**只有「欠钱且无人兜底(无主报账人)」的单**才会继续被 584082 拦截。
- **新增财务应收台账只读分页接口**:`GET /admin/finance/receipt/receivable/page`,订单维度看应收/已收/代收/欠收,代收列与欠收列互斥(指定主报账人后欠收挪入代收)。
---
## 一、接口背景
尾款收款模型重构 Epic(方案甲·全实时,不建债表)。旧模型里尾款由司导线下代收,经过「现场垫付 → 报销 → 核单 → 支付完成」长链路后才回写订单已付金额,导致:
- 核单时「尾款未收齐」一律硬拦,司导已代收但还没走完报销回写的单被卡死;
- 财务看不到「这笔钱到底在谁手里」——是客户还欠着,还是司导代收未回款。
新模型的三条规则:
1. **金额实时算**:应收/已收/欠收全部实时计算,不落地中间债表;
2. **归属实时读主报账人**:订单层有主报账人(reporterRank=PRIMARY)时,未收齐部分视为「主报账人代收中」;没有主报账人时才是「客户欠收」;
3. **结清看核单终态**:核单完成(FINALIZED)后由财务域支付完成事件回写已付金额,闭环。
本次三个接口分别对应:确认行程时把「主报账人」变成前置条件(接口1)、核单欠收硬闸按主报账人豁免(接口2)、财务侧新增应收台账把代收/欠收分列展示(接口3)。
---
## 二、变更清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 确认行程 | POST | `/v3/admin/order/{orderId}/confirm-itinerary` | 行为变更 + 新错误码 | 确认前必须已有主报账人,否则报 581062;入参出参结构不变 |
| 2 | 提交核单(完成核单) | POST | `/v3/admin/order/{orderId}/settlement/finalize` | 行为变更 | 584082 只拦「无主报账人且欠收」的单;有主报账人放行,欠收走代收口径留痕 |
| 3 | 财务应收台账分页 | GET | `/admin/finance/receipt/receivable/page` | **新增接口** | 订单维度应收/已收/代收/欠收分页,只读 |
> 说明:接口2 任务背景里常被称为「核单提交/生成报账」链路,硬闸实际落在「完成核单」写接口上;`GET /v3/admin/order/{orderId}/settlement/reports/reimbursement`(查询主报账人报账表)出参结构**无任何变化**。
---
## 三、接口详情
### 接口1:确认行程 `POST /v3/admin/order/{orderId}/confirm-itinerary`
#### 使用场景
订单详情页点「确认行程」,把订单从「定制中」推进到「待出行」。普通订单在确认弹框中选择本单主报账人(尾款代收人)后提交;团期子订单由团期扇出自动带主报账人,一般无需选择。
- 认证:管理后台 JWT
- 幂等性:非幂等写操作,重复确认会被状态机拦截(订单已不在「定制中」)
- 限流:无
#### 入参
| 字段 | 位置 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| orderId | Path | Long | 是 | 订单 ID |
| reporterAssignmentId | Body | Long | 否 | 新的主报账人人员安排 ID;不传则沿用当前主报账人。**注意:本单当前没有主报账人时,不传会被 581062 拦截** |
请求体整体可空(`{}` 或不传 body),但仅当订单已有主报账人时才能通过。
#### 出参 `Result<OrderTransitionRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| success | Boolean | 是否成功 |
| oldStatus | String | 变更前订单状态(确认成功时恒为 `CUSTOMIZING`) |
| newStatus | String | 变更后订单状态(确认成功时恒为 `PENDING_DEPARTURE`) |
| oldFlowStatus | String | 变更前流程状态 |
| newFlowStatus | String | 变更后流程状态 |
| triggeredEvents | Array<String> | 本次转换派生的事件列表 |
#### 业务边界
- 仅「定制中」订单可确认;5 项前置 checklist 未全过时仍报 581036(既有行为不变)。
- 传了 `reporterAssignmentId` 会在确认前先把该人员置为主报账人,再校验主报账人存在性——即「弹框选人」与「确认」是一步完成的。
- **团期子订单**:团期人员扇出后订单层已有主报账人副本,不传 `reporterAssignmentId` 也放行;扇出延迟窗口期(极短)可能暂无主报账人被 581062 拦截,稍候重试即可。
- `reporterAssignmentId` 传非数字/非法格式报 581046(既有行为不变)。
#### 示例
典型成功(确认弹框选了主报账人)见「八、示例」8.1;无主报账人被拦见 8.3。
---
### 接口2:提交核单(完成核单)`POST /v3/admin/order/{orderId}/settlement/finalize`
#### 使用场景
核单页核对完主报账、单团核算后点「提交核单/完成核单」,冻结核单快照并把订单推进到已核单。
- 认证:管理后台 JWT
- 幂等性:非幂等写操作,重复提交会被核单状态拦截
- 限流:无
#### 入参
| 字段 | 位置 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| orderId | Path | Long | 是 | 订单 ID(无请求体) |
#### 出参
`Result<SettlementSubmitRespVO>`(核单提交结果,结构无变化)。
#### 业务边界(本次核心变化)
| 场景 | 变更前 | 变更后 |
|------|--------|--------|
| 尾款未收齐 + **无主报账人** | 报 584082 拦截 | 报 584082 拦截(不变) |
| 尾款未收齐 + **已有主报账人** | 报 584082 拦截 | **放行**,欠收记为主报账人代收口径 |
| 尾款已收齐 | 放行 | 放行(不变) |
- 「放行」不等于「欠收已清」:欠收金额会留在报账快照的欠收勾稽字段里,由财务域下游追款兜底,前端不要把「提交成功」理解为「钱已收齐」。
- 代收口径:应代收 = 核单总额 − 客户线上已付(`paidAmount`)。旧的「司机现金代收登记」项已废止,代收不再计入已付金额。
- 配套读接口 `GET /v3/admin/order/{orderId}/settlement/reports/reimbursement`(查询主报账人报账表)出参结构无变化。
#### 示例
欠收但有主报账人提交成功、无主报账人被 584082 拦截,见「八、示例」8.2 / 8.3。
---
### 接口3:财务应收台账分页 `GET /admin/finance/receipt/receivable/page`(新增)
#### 使用场景
财务「应收台账」页,按订单维度查看应收/已收/代收/欠收,用于内部财务对账与追款。
- 认证:管理后台 JWT(网关注入)
- 幂等性:只读
- 限流:无
#### 入参(Query)
| 字段 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| page | Integer | 否 | 1 | 页码 |
| pageSize | Integer | 否 | 20 | 每页条数 |
| keyword | String | 否 | 空 | 搜索关键字(团号 / 客户姓名 / 产品名 / 订单号 模糊);空=不限 |
| receivableStatus | String | 否 | 空 | 收款状态筛选:`UNPAID` / `PARTIAL` / `DONE`;空或非法值=不按状态过滤 |
#### 出参 `Result<PageResult<ReceiptReceivableRowRespVO>>`
分页外层固定为 `records` / `total` / `page` / `pageSize`。`records[]` 行字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | String | 订单 ID(字符串防精度丢失) |
| orderNo | String | 订单号 |
| teamNo | String | 团号 |
| productName | String | 产品名 |
| customerName | String | 客户姓名 |
| customerPhone | String | 客户手机号(**明文**,内部财务对账用) |
| orderStatus | String | 订单粗状态枚举值(见「六、枚举」) |
| orderStatusName | String | 订单粗状态中文名 |
| receivableStatus | String | 收款状态:`UNPAID` 待收款 / `PARTIAL` 部分收款 / `DONE` 已收讫 |
| receivableStatusName | String | 收款状态中文名 |
| receivableAmount | Number | 应收总额(取消单归 0) |
| paidAmount | Number | 已收金额(毛额,退款不回减) |
| collectedAmount | Number | 代收金额(主报账人代收中的实时金额;取消单归 0) |
| balanceAmount | Number | 欠收金额(未指定主报账人的实时欠收;取消单归 0) |
#### 业务边界
- **代收列与欠收列互斥**:同一行 `collectedAmount` 与 `balanceAmount` 必有一列为 0——订单指定了主报账人,未收齐部分进代收列;未指定主报账人,进欠收列。两列合计恒等于该单实时待收尾款。
- 收款状态由金额派生:已收=0 → `UNPAID`;已收>0 且仍有欠收/代收 → `PARTIAL`;欠收代收均=0 且已收>0 → `DONE`。
- 只读接口,无登记/核销按钮;追款动作不在本接口。
- 本接口在 hl-finance 服务(`/admin/finance/*`),与订单域接口不同服务,但同一网关入口。
#### 示例
见「八、示例」8.1 / 8.2。
---
## 四、接口入参
各接口入参已分别内联在「三、接口详情」各小节,不再汇总大表。
## 五、出参字段
各接口出参已分别内联在「三、接口详情」各小节,不再汇总大表。
## 六、枚举 / 数据字典
### 6.1 reporterRank(报账人等级,接口1 相关概念)
| 值 | 中文名 | 说明 |
|----|--------|------|
| PRIMARY | 主报账人 | 单内唯一;本次起确认行程前必须存在(尾款代收人) |
| SECONDARY | 次报账人 | 单内唯一,不满足接口1 的前置条件 |
| NONE | 非报账人 | 默认值 |
### 6.2 receivableStatus(收款状态,接口3 行字段 + 筛选项)
| 值 | 中文名 | 派生条件 |
|----|--------|----------|
| UNPAID | 待收款 | 已收金额 = 0 |
| PARTIAL | 部分收款 | 已收 > 0 且(欠收 + 代收)> 0 |
| DONE | 已收讫 | 欠收 + 代收 = 0 且已收 > 0 |
### 6.3 orderStatus(订单粗状态,接口3 行字段)
| 值 | 中文名 |
|----|--------|
| PENDING_PAY | 待支付 |
| CUSTOMIZING | 定制中 |
| PENDING_DEPARTURE | 待出行 |
| TRAVELLING | 出行中 |
| COMPLETED | 已完成 |
| CANCELLED | 已取消 |
## 七、错误码
| 错误码 | 报文 | 触发接口 | 说明 |
|--------|------|----------|------|
| **581062** | 请先指定本单主报账人(尾款代收人) | 接口1 确认行程 | **本次新增**。确认前置换后仍无主报账人时抛出;团期单扇出延迟窗口期被拦属预期,稍候重试 |
| 581036 | 确认订单前置校验未通过,请先补全所有必填项 | 接口1 确认行程 | 既有。5 项 checklist 未全过 |
| 581046 | 报账人ID格式非法,须为有效的数字ID | 接口1 确认行程 | 既有。`reporterAssignmentId` 格式非法 |
| 584082 | 存在待收尾款,请收齐后再提交核单 | 接口2 提交核单 | 既有但**触发条件收紧**:现在仅「无主报账人且欠收不为 0」才抛出;有主报账人的欠收单不再触发 |
## 八、示例
### 8.1 典型成功
**接口1:确认行程(确认弹框选了主报账人)**
```http
POST /v3/admin/order/2086272700140957697/confirm-itinerary
Authorization: Bearer <admin token>
Content-Type: application/json
{
"reporterAssignmentId": 2072930844657283074
}
```
```json
{
"code": 200,
"message": "成功",
"data": {
"success": true,
"oldStatus": "CUSTOMIZING",
"newStatus": "PENDING_DEPARTURE",
"oldFlowStatus": "CUSTOMIZING",
"newFlowStatus": "CONFIRMED",
"triggeredEvents": ["CHECKLIST_CONFIRMED"]
},
"success": true
}
```
**接口3:应收台账分页(指定了主报账人的在途单,欠收进代收列)**
```http
GET /admin/finance/receipt/receivable/page?page=1&pageSize=20&keyword=26-0001
Authorization: Bearer <admin token>
```
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [
{
"id": "2086272700140957697",
"orderNo": "HL20260920001",
"teamNo": "26-0001",
"productName": "呼伦贝尔草原 5 日游",
"customerName": "张三",
"customerPhone": "13800001111",
"orderStatus": "TRAVELLING",
"orderStatusName": "出行中",
"receivableStatus": "PARTIAL",
"receivableStatusName": "部分收款",
"receivableAmount": 12800.00,
"paidAmount": 6400.00,
"collectedAmount": 6400.00,
"balanceAmount": 0
}
],
"total": 1,
"page": 1,
"pageSize": 20
},
"success": true
}
```
### 8.2 边界情况
**接口2:尾款未收齐但已有主报账人——变更前被拦、变更后放行**
```http
POST /v3/admin/order/2086272700140957697/settlement/finalize
Authorization: Bearer <admin token>
```
```json
{
"code": 200,
"message": "成功",
"data": { "submitted": true },
"success": true
}
```
> 注意:提交成功不代表欠收已清,欠收金额留在报账快照勾稽字段中,由财务域下游追款。
**接口3:未指定主报账人的订单——同一笔未收齐金额进欠收列**
```json
{
"receivableStatus": "PARTIAL",
"receivableStatusName": "部分收款",
"receivableAmount": 12800.00,
"paidAmount": 6400.00,
"collectedAmount": 0,
"balanceAmount": 6400.00
}
```
**接口3:空结果(关键词无匹配)**
```json
{
"code": 200,
"message": "成功",
"data": { "records": [], "total": 0, "page": 1, "pageSize": 20 },
"success": true
}
```
### 8.3 业务失败
**接口1:无主报账人且弹框未选人 → 581062**
```http
POST /v3/admin/order/2086272700140957697/confirm-itinerary
Authorization: Bearer <admin token>
Content-Type: application/json
{}
```
```json
{
"code": 581062,
"message": "请先指定本单主报账人(尾款代收人)",
"data": null,
"success": false
}
```
**接口2:尾款未收齐且无主报账人 → 584082(唯一仍会被拦的情形)**
```http
POST /v3/admin/order/2086272700140957697/settlement/finalize
Authorization: Bearer <admin token>
```
```json
{
"code": 584082,
"message": "存在待收尾款,请收齐后再提交核单",
"data": null,
"success": false
}
```
## 九、业务边界
- 接口1 仅适用「定制中 → 待出行」确认动作;其他状态变更不受影响。
- 接口1 团期子订单正常路径无需前端传参(扇出已带主报账人);扇出延迟窗口被 581062 拦时引导「稍候重试」即可,不要当成数据异常。
- 接口2 放行后核单可正常完成,但财务仍会在应收台账/报账勾稽里看到代收欠收金额;「已核单」不等于「已收讫」。
- 接口3 为只读台账,不提供任何写操作;`customerPhone` 为明文,仅限内部财务对账场景使用,前端不要在非财务页面引用该接口。
- 接口3 已取消订单的金额列全部归 0。
## 十、修改前后对比
### 10.1 字段级对比
| 接口 | 字段 | 变更 |
|------|------|------|
| 接口1 确认行程 | 入参/出参全部字段 | 无变化(`reporterAssignmentId` 仍为非必填,但语义从「纯可选置换」变为「无主报账人时事实必填」) |
| 接口2 提交核单 | 入参/出参全部字段 | 无变化 |
| 接口3 应收台账 | 全部字段 | 新增接口,无对比 |
### 10.2 行为级对比
| 场景 | 变更前 | 变更后 |
|------|--------|--------|
| 确认行程时本单无主报账人 | 直接确认成功 | 报 581062,确认不发生;传 `reporterAssignmentId` 选人后放行 |
| 核单时尾款未收齐、有主报账人 | 报 584082 拦死 | 放行,欠收记主报账人代收口径 |
| 核单时尾款未收齐、无主报账人 | 报 584082 拦死 | 报 584082 拦死(不变) |
| 应代收口径 | 核单总额 −(已付 − 司机现金代收登记合计) | 核单总额 − 已付(司机现金代收登记项已废止) |
| 财务看尾款归属 | 无接口可看 | 应收台账代收/欠收互斥分列 |
## 十一、影响评估 / 回滚
- **破坏性**:接口1 对「此前无主报账人也能确认」的流程是行为收紧,普通订单确认弹框**必须**支持选择主报账人并传 `reporterAssignmentId`,否则确认会被 581062 拦截——**前端需要同步上线**。
- 接口2 是放行面扩大,前端对 584082 的既有提示逻辑继续有效(触发面变窄),无强制改动;但原来「被拦 → 引导收尾款」的引导文案对「有主报账人」场景不再出现,如有相关 workaround 可清理。
- 接口3 纯新增,不影响存量页面。
- **回滚**:后端回滚后,接口1 恢复「无主报账人也可确认」、接口2 恢复「欠收一律拦」、接口3 下线(请求返回 404)。回滚期间前端确认弹框保留选人逻辑无副作用(多传字段旧版兼容)。
## 十二、注意事项
- 确认弹框的主报账人候选来自本单人员安排,`reporterAssignmentId` 传人员安排 ID(不是用户 ID、不是员工编号)。
- 581062 与 581036 可能先后出现:先补 checklist(581036),再补主报账人(581062),前端引导顺序建议先 checklist 后选人。
- 「确认成功」「核单提交成功」都不代表尾款已收齐;尾款是否收讫以应收台账 `receivableStatus=DONE` 为准。
- 应收台账的代收/欠收两列互斥,前端渲染时不要对两列同时展示非 0 值做兜底合并——合计即实时待收尾款。
- 本 Epic 还包含财务域支付完成后回写订单已付金额的配套链路(PR-5b 起),对管理后台 REST 契约无新增字段,不单独列接口。
## 十三、关联 / 联系人
- Issue(主):https://git.1814.love/wx/HL/issues/8248
- Issue(确认行程主报账人必填化):https://git.1814.love/wx/HL/issues/8265
- Issue(财务应收台账):https://git.1814.love/wx/HL/issues/8191
- PR-6a(接口1):https://git.1814.love/wx/HL/pulls/8274 | merge commit:https://git.1814.love/wx/HL/commit/f6b1960a93
- PR-5a(接口2):https://git.1814.love/wx/HL/pulls/8263 | merge commit:https://git.1814.love/wx/HL/commit/a480f3dacd
- PR-3(接口3):https://git.1814.love/wx/HL/pulls/8207 | merge commit:https://git.1814.love/wx/HL/commit/768136626e
- 后端负责人:yst(腰苏图)