--- schema: "hl-changelog/v2" ticket: "8248" title: "尾款收款模型重构:确认行程主报账人必填化 + 核单欠收硬闸按主报账人豁免 + 财务应收台账" consumer: "admin" author: "yst" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "b556da24791ca0ecf2393ef0e8505989c7996f8a" target_release: "v2.1" 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 现网零漂移。 | 2026-09-24 mmg 交付:①LockModal 无主报账人选人必填化(星号+引导+置灰双保险);②两接口 JSDoc 补 581062/584082 新口径;③新增应收台账只读页(api/finance/receipt.js+finance/receipt/receivable,原型 recv-ledger 对齐,代收欠收互斥直显,订单号链接钻取);spec 5+3+2 例全绿;⚠菜单「收款管理/应收台账」依赖后端 sys_menu 补配(changelog 未提及,请后端补配)" 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` | 字段 | 类型 | 说明 | |------|------|------| | success | Boolean | 是否成功 | | oldStatus | String | 变更前订单状态(确认成功时恒为 `CUSTOMIZING`) | | newStatus | String | 变更后订单状态(确认成功时恒为 `PENDING_DEPARTURE`) | | oldFlowStatus | String | 变更前流程状态 | | newFlowStatus | String | 变更后流程状态 | | triggeredEvents | Array | 本次转换派生的事件列表 | #### 业务边界 - 仅「定制中」订单可确认;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`(核单提交结果,结构无变化)。 #### 业务边界(本次核心变化) | 场景 | 变更前 | 变更后 | |------|--------|--------| | 尾款未收齐 + **无主报账人** | 报 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>` 分页外层固定为 `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 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 ``` ```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 ``` ```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 Content-Type: application/json {} ``` ```json { "code": 581062, "message": "请先指定本单主报账人(尾款代收人)", "data": null, "success": false } ``` **接口2:尾款未收齐且无主报账人 → 584082(唯一仍会被拦的情形)** ```http POST /v3/admin/order/2086272700140957697/settlement/finalize Authorization: Bearer ``` ```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(腰苏图)