--- schema: "hl-changelog/v2" ticket: "8161" title: "收票核销硬钩稽 PR-1——登记/编辑挂票收紧为生效态门槛(新增错误码 599412)+ 新增单据维度收票对账下钻 biz-recon" consumer: "admin" author: "yst(GIT)" change_type: "修改接口" backend_status: "merged" gateway_status: "pending" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "5072da0b991cdcde4cc83cc5be94072eb049d224" target_release: "v2.1" verified_at: "2026-09-22" status_note: "收票(进项发票)域挂票规则收紧:登记(create)/编辑(update)挂 PAYMENT/PREPAY 关联单时新增生效态硬校验,非 APPROVED/PAID 抛新错误码 599412(此前可挂草稿/审批中/已驳回单);同时新增单据维度收票对账下钻 GET /admin/finance/invoice-in/biz-recon。EXPENSE 关联规则不变。已合并 dev-v3(PR #8163,merge commit 90739708e5),待部署测试服后验证。前端走 biz-candidates 勾选关联的链路天然不触发 599412(候选只出 APPROVED/PAID 单);若有手填 bizId 或复用存量草稿单关联的入口需处理 599412 提示。 前端已交付(5072da0b):invoice-in.js 新增 getInvoiceInBizRecon,详情「关联业务明细」行级「对账」下钻弹窗(五档金额后端字段直显、unmatchedAmount 负值红字不截断、EXPENSE 供应商空值兜底);599412 经核验前端全走 biz-candidates 勾选天然不触发,零改动。" updated_at: "2026-09-22" base: "dev-v3" --- # 收票核销硬钩稽 PR-1:挂票生效态门槛(599412)+ 单据维度收票对账下钻 biz-recon(管理后台) > **服务**: hl-order-service-v3(hl-finance 模块,端口 8086/8186) > **PR**: #8163 > **Issue**: #8161 > **日期**: 2026-09-22 > **影响范围**: 管理后台「财务管理 - 收票管理(进项发票)」的登记/编辑弹窗(挂关联业务单据)、供应商收票对账页的「按单据下钻」能力 --- ## 一、接口背景 收票(进项发票)域已上线(#7857),主链为「登记收票 → 核对 → 作废」,供应商为对账锚点。 本次 PR 解决两个缺口: 1. **挂票门槛缺失**:此前登记(create)/ 编辑(update)发票挂关联业务单据时,后端只验「单据存在 + 归属本供应商」,**不验单据状态**——发票可以挂到草稿(PENDING)/ 审批中(SUBMITTED)/ 已驳回(REJECTED)的应付款、预付款单上。而这些非生效态单据的金额还可能被修改、单据还可能被驳回,造成「钱变了票不知道」,对账口径失真。本次对 PAYMENT / PREPAY 增加生效态硬校验:只有 **APPROVED(已批准)/ PAID(已付讫)**(金额已冻结、无变更入口)的单据才可挂票,否则抛新错误码 **599412**。 2. **对账无法下钻**:供应商收票对账(supplier-recon)只有供应商维度合计,看不到单张业务单据的收票进度。本次新增单据维度下钻接口 biz-recon,按 bizType + bizId 查单张单据的应收票额 / 已匹配额(拆「已核对」「已收票未核对」两档)/ 未匹配额。 ## 二、变更清单 | # | 接口 | 方法 | 路径 | 变更类型 | |---|------|------|------|----------| | 1 | 单据维度收票对账下钻 | GET | /admin/finance/invoice-in/biz-recon | 新增接口 | | 2 | 登记进项发票 | POST | /admin/finance/invoice-in/create | 行为收紧:PAYMENT/PREPAY 关联单须 APPROVED/PAID,否则 599412 | | 3 | 编辑进项发票 | PUT | /admin/finance/invoice-in/update | 行为收紧:同 #2(关联走全删重插,门槛自动覆盖存量数据) | 入参结构、出参结构对 #2 #3 均**零变化**(字段名/类型/必填全不变),仅服务端校验规则收紧 + 新增一个错误码。 ## 三、接口详情 | 接口 | 使用场景 | 认证 | 幂等性 | 限流 | |------|----------|------|--------|------| | GET /admin/finance/invoice-in/biz-recon | 收票对账页从供应商/单据行下钻,看单张应付/预付/费用报销单的收票进度与未匹配额 | 管理后台 JWT | 只读,天然幂等 | 网关默认 | | POST /admin/finance/invoice-in/create | 登记一张进项发票并可挂多笔业务单据 | 管理后台 JWT | 非幂等(发票号码唯一,重复登记 599401) | 网关默认 | | PUT /admin/finance/invoice-in/update | 编辑已收票(RECEIVED)态发票,关联全删重插 | 管理后台 JWT | 非幂等(同内容重复提交幂等) | 网关默认 | ## 四、接口入参 ### 4.1 biz-recon Query 参数 | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | bizType | String | 是 | 业务类型:PAYMENT 应付款 / PREPAY 预付款 / EXPENSE 费用报销(大小写不敏感,取值域外按 599407 处理) | | bizId | Long | 是 | 业务单据 ID(应付单/预付单/费用报销单主键;查无此单 → 599407) | ### 4.2 create / update 请求体关联字段(结构不变,仅校验收紧) 请求体整体结构同既有版本(发票号码/发票类型/供应商/价税合计/日期/影像/备注等,见 #7857 changelog),本次只涉及 relations 数组元素的校验规则: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | relations[].bizType | String | 是 | PAYMENT / PREPAY / EXPENSE | | relations[].bizId | Long | 是 | 业务单据 ID。**新规则**:PAYMENT/PREPAY 单据状态必须是 APPROVED 或 PAID,否则整单拒绝并报 599412;EXPENSE 维持只验存在 | | relations[].matchAmount | BigDecimal | 是 | 本票对该笔的匹配金额(>0;ΣmatchAmount > 票面金额 → 599406) | ## 五、出参字段(biz-recon → InvoiceInBizReconRespVO) 统一响应包装 Result<InvoiceInBizReconRespVO>,data 字段如下: | 字段 | 类型 | 说明 | |------|------|------| | bizType | String | 业务类型英文码(PAYMENT/PREPAY/EXPENSE) | | bizTypeName | String | 业务类型中文名(应付款/预付款/费用报销),**直接展示,勿前端再硬编码 map** | | bizId | String | 业务单据 ID(Long 主键序列化为**字符串**,防 JS 精度丢失) | | bizNo | String | 业务单据号(快照,如应付单号/预付单号/报销单号) | | billStatus | String | 单据状态英文码:PENDING/SUBMITTED/APPROVED/REJECTED/PAID | | billStatusName | String | 单据状态中文名(草稿/审批中/已批准/已驳回/已付讫),直接展示 | | supplierId | String 或 null | 供应商 ID(Long 序列化为字符串);**EXPENSE 无供应商锚点,恒为 null** | | supplierName | String 或 null | 供应商名称(快照);EXPENSE 恒为 null | | billAmount | BigDecimal | 应收票额:PAYMENT 取实付金额 actual_pay_amount / PREPAY 取金额 / EXPENSE 取金额 | | matchedVerifiedAmount | BigDecimal | 已核对票匹配额:该单关联的 **VERIFIED** 发票的 Σmatch_amount(剔作废票) | | matchedReceivedAmount | BigDecimal | 已收票未核对匹配额:该单关联的 **RECEIVED** 发票的 Σmatch_amount(剔作废票) | | matchedAmount | BigDecimal | 已匹配额合计 = matchedVerifiedAmount + matchedReceivedAmount | | unmatchedAmount | BigDecimal | 未匹配额 = billAmount − matchedAmount,**可为负数(= 多收票),前端展示不要 max(0,·) 截断** | | matchedInvoiceCount | Long | 匹配发票张数(剔作废票,JSON number 非字符串);无任何匹配时为 0,各匹配额为 0 | ## 六、枚举 / 数据字典 ### 6.1 bizType(业务类型) | 值 | 中文名 | 说明 | |----|--------|------| | PAYMENT | 应付款 | 应收票额取实付金额(actual_pay_amount,含冲抵后口径) | | PREPAY | 预付款 | 应收票额取金额 | | EXPENSE | 费用报销 | 无供应商锚点(supplierId/supplierName 为 null),不进供应商对账 | ### 6.2 billStatus(单据状态,应付/预付/费用报销三域状态名一致) | 值 | 中文名 | 是否可挂票 | |----|--------|-----------| | PENDING | 草稿 | 否(599412) | | SUBMITTED | 审批中 | 否(599412) | | APPROVED | 已批准 | **是** | | REJECTED | 已驳回 | 否(599412) | | PAID | 已付讫 | **是** | > 「是否可挂票」只对 PAYMENT/PREPAY 强制;EXPENSE 不验状态。 ### 6.3 发票状态(匹配额拆分依据,本次无新增值) | 值 | 中文名 | 是否计入匹配额 | |----|--------|---------------| | RECEIVED | 已收票 | 计入 matchedReceivedAmount | | VERIFIED | 已核对 | 计入 matchedVerifiedAmount | | VOIDED | 已作废 | **不计入**(作废自动释放匹配额) | ## 七、错误码 | 错误码 | 常量 | 消息 | 触发场景 | |--------|------|------|----------| | **599412** | INVOICE_IN_REL_BIZ_NOT_EFFECTIVE | 关联业务单据未生效(应付/预付须已批准或已付讫才可收票) | **本次新增**。create/update 的 relations 挂 PAYMENT/PREPAY 且单据状态非 APPROVED/PAID 时抛出,整单拒绝 | | 599407 | INVOICE_IN_REL_BIZ_NOT_FOUND | 关联业务单据不存在 | biz-recon:bizType 取值域外或 bizId 查无此单;create/update:单据不存在、或不归属本发票的开票供应商 | | 599405 | INVOICE_IN_AMOUNT_INVALID | 发票金额非法 | relations[].matchAmount 为空或 ≤0(既有) | | 599406 | INVOICE_IN_AMOUNT_EXCEEDED | 匹配金额合计超过票面金额 | ΣmatchAmount > invoiceAmount(既有) | | 599411 | INVOICE_IN_REL_DUPLICATED | 同一张票重复关联同一业务单据 | 同一请求内 bizType+bizId 重复(既有) | **599412 与 599407 的区别**:407 = 单据不存在 / 不归属本供应商;412 = 单据存在且归属正确,但**状态未生效**。前端错误提示文案可直接用 message,也可自行区分「选错单」与「单未生效」两种引导。 ## 八、示例 ### 8.1 典型成功 —— 应付单下钻,部分收票 请求: GET /admin/finance/invoice-in/biz-recon?bizType=PAYMENT&bizId=1982736450011223 响应: ```json { "code": 0, "data": { "bizType": "PAYMENT", "bizTypeName": "应付款", "bizId": "1982736450011223", "bizNo": "PAY-202609-0042", "billStatus": "PAID", "billStatusName": "已付讫", "supplierId": "1877665544332211", "supplierName": "满洲里蓝天旅行社有限公司", "billAmount": 12000.00, "matchedVerifiedAmount": 8000.00, "matchedReceivedAmount": 2000.00, "matchedAmount": 10000.00, "unmatchedAmount": 2000.00, "matchedInvoiceCount": 2 }, "msg": "" } ``` ### 8.2 边界情况 —— EXPENSE 无匹配发票(各匹配额为 0、供应商字段为 null) 请求: GET /admin/finance/invoice-in/biz-recon?bizType=EXPENSE&bizId=1966112233445566 响应: ```json { "code": 0, "data": { "bizType": "EXPENSE", "bizTypeName": "费用报销", "bizId": "1966112233445566", "bizNo": "EXP-202609-0118", "billStatus": "APPROVED", "billStatusName": "已批准", "supplierId": null, "supplierName": null, "billAmount": 3500.00, "matchedVerifiedAmount": 0, "matchedReceivedAmount": 0, "matchedAmount": 0, "unmatchedAmount": 3500.00, "matchedInvoiceCount": 0 }, "msg": "" } ``` > 另一典型边界:**多收票**时 unmatchedAmount 为负数。例如 billAmount=10000、matchedAmount=12000,则 unmatchedAmount=-2000.00,属于有意返回,请原样展示(负值即「多收票」预警)。 ### 8.3 业务失败 —— 登记发票挂「草稿态」应付单触发 599412 请求: POST /admin/finance/invoice-in/create Content-Type: application/json { "invoiceNo": "INV-2026-092201", "invoiceType": "SPECIAL", "supplierId": 1877665544332211, "invoiceAmount": 5000.00, "invoiceDate": "2026-09-20", "receiveDate": "2026-09-22", "relations": [ { "bizType": "PAYMENT", "bizId": 1982736450000001, "matchAmount": 5000.00 } ] } (该应付单状态为 PENDING 草稿)响应: ```json { "code": 599412, "data": null, "msg": "关联业务单据未生效(应付/预付须已批准或已付讫才可收票)" } ``` 整单拒绝,发票不落库。update 链路表现相同。 ## 九、业务边界 **适用场景** - 收票对账页:从供应商对账行 / 单据行下钻,看单张应付/预付/费用报销单的收票进度(biz-recon) - 登记/编辑发票:挂已批准或已付讫的应付/预付单、任意状态的费用报销单 **不适用场景** - biz-recon 是**单查**接口(一次一单),不是分页列表,不要拿它批量刷数 - EXPENSE 费用报销无供应商锚点,不进供应商维度对账(supplier-recon 只覆盖应付/预付);biz-recon 支持查 EXPENSE 仅用于展示 - 非生效态应付/预付单**不可**作为挂票对象(本次收紧点),「先挂票后补批」的流程不再可行 **特殊边界** - 通过「可关联业务单据候选」接口(GET /admin/finance/invoice-in/biz-candidates)勾选的链路**天然不触发 599412**——候选接口本来就只返回 APPROVED/PAID 生效单,与本次门槛口径一致。只有手填 bizId、直调接口、或编辑「存量挂草稿单」的发票时才可能撞上 599412 - 作废(VOIDED)发票自动释放其匹配额,释放后 biz-recon 的各匹配额即时下降,无需前端额外刷新逻辑(重新调接口即可) ## 十、修改前后对比 | 维度 | 修改前 | 修改后 | |------|--------|--------| | create/update 挂 PAYMENT/PREPAY | 只验「单据存在 + 归属本供应商」,草稿/审批中/已驳回单**均可**挂票成功 | 额外验状态:非 APPROVED/PAID → 整单拒绝,报 **599412** | | create/update 挂 EXPENSE | 只验存在 | **不变**,仍只验存在 | | update 存量数据 | 编辑时关联全删重插,旧校验不拦非生效单 | 编辑一张「存量挂草稿单」的发票时,重插校验触发 599412,需先移除失效关联才能保存 | | 单据维度对账 | 无接口(只有供应商维度 supplier-recon) | 新增 GET /admin/finance/invoice-in/biz-recon | | 入参/出参字段结构 | — | **零变化**(无字段增删改名;仅新增 biz-recon 接口与 599412 错误码) | ## 十一、影响评估 / 回滚 **是否破坏兼容**:行为级破坏(窄面)。原先「挂非生效态应付/预付单」能成功的请求现在报 599412 失败;接口签名、字段、其余错误码均不变。 **前端需要同步上线吗**:不需要强同步,但建议排查: - 若登记/编辑弹窗的关联单来源**全部**走 biz-candidates 候选勾选 → 零改动,不会触发 599412 - 若存在**手填 bizId / 直调接口 / 复制历史发票**等绕过候选的入口 → 需要把 599412 纳入错误提示(文案可直接用 msg),并引导用户先完成单据审批 - 若有「多收票」展示位置 → 注意 unmatchedAmount 可为负,不要做 max(0, x) 截断 **回滚方案**:后端回滚本 PR 即恢复旧行为(不验状态、biz-recon 404);无 DDL、无 Nacos 配置变更,前端无需配合回滚。存量已挂非生效单的关联行**不会被追溯清理**,仍正常计入匹配额。 ## 十二、注意事项 1. **两个对账接口是「有意的不同口径」,不要互相对数**: - biz-recon 的 matchedAmount = **Σ match_amount(关联行口径)**,计 VERIFIED + RECEIVED 两档发票对该单的匹配金额之和 - supplier-recon 的 receivedAmount = **Σ invoice_amount(整票口径)**,仅计 VERIFIED 发票的整票价税合计 - 一张 RECEIVED 未核对的票:在 biz-recon 里计入 matchedReceivedAmount,在 supplier-recon 里**不计**;一张票只部分匹配某单时:biz-recon 计 match_amount 部分,supplier-recon 核对后计整票 invoice_amount。两者天然不等,matchedVerifiedAmount / matchedReceivedAmount 拆两档正是为解释这个差异。前端不要把两个接口的金额互相核对相等,也不要混用字段名 2. bizId / supplierId 是 **JSON 字符串**(Long 经 ToStringSerializer 序列化,防 JS 精度丢失);matchedInvoiceCount 是普通 JSON number 3. bizTypeName / billStatusName 由后端返回中文,直接展示,**不要**再在前端维护硬编码 map 4. EXPENSE 单的 supplierId / supplierName 恒为 null,展示时注意空值兜底 5. biz-recon 对非法 bizType、不存在的 bizId 统一报 599407,不区分「类型错」与「单不存在」 ## 十三、关联 / 联系人 - **Issue**: [#8161 收票核销硬钩稽](https://git.1814.love:8443/wx/HL/issues/8161) - **PR**: [#8163 feat(finance): 收票核销硬钩稽 PR-1——挂票生效态门槛 + 单据维度对账下钻接口](https://git.1814.love:8443/wx/HL/pulls/8163) - **Commit**: [90739708e5](https://git.1814.love:8443/wx/HL/commit/90739708e5b7c5630233c3a3cf72645d6c0c9a5c) - **设计文档(后端内部)**: 收票核销硬钩稽设计(PR-1 挂票门槛 + biz-recon 下钻;PR-2 钱变票钩子后续另行推送) - **后端负责人**: 腰苏图(yst)