# Phase 1 API Contracts: 订单 ezPay 电子发票开立 **Date**: 2026-06-16 后端 REST 接口契约。客户端开票挂在 `UserOrderController`;管理端(商家+平台)查看/重试/作废用独立 `PosOrderInvoiceController`。 ## 一、客户端(客户为自己的订单开票) 挂在 `UserOrderController`(`@RequestMapping("/system/userOrder")`,已有 JWT 鉴权)。 ### POST `/system/userOrder/applyInvoice` 客户对已完成订单申请开票。 **请求体** `ApplyInvoiceDto`: **① 字段含义** | 字段 | 类型 | 含义 | 格式 / 示例 | |------|------|------|------------| | orderId | Long | 要开票的订单 id | 必须是当前登录客户自己的订单,且已完成 `state=3`、已支付 `payStatus=1` | | category | String | 发票类型 | `B2C`=个人发票(载具);`B2B`=公司发票(统编报账);`DONATION`=捐赠发票(带 loveCode 捐给社福机构,服务端翻译成 ezPay B2C+LoveCode) | | buyerName | String | 买方名称 | B2C 填个人姓名;B2B 填公司全名 | | buyerUbn | String | 买方统一编号(统编) | 8 位数字,如 `12345678`。**仅 B2B 用** | | buyerEmail | String | 接收发票的邮箱 | 可选。B2C 填了则发邮件通知,不填则客户在系统查看 | | carrierType | String | 电子载具类型 | `0`=手机条码 / `1`=自然人凭证 / `2`=ezPay 会员载具;**留空 = 不用载具** | | carrierNum | String | 载具号码 | 随 carrierType:手机条码以 `/` 开头(`/ABC1234`);自然人凭证 `2字母+14数字`;ezPay 会员载具填会员账号 | **② 什么时候必填(按场景,对齐 ezPay 开票模型)** | 场景 | buyerUbn | buyerEmail | carrierType + carrierNum | |------|----------|------------|--------------------------| | **B2B 公司发票** | ✅ 必填(8 位数字) | ✅ 必填(ezPay 发开立通知给买方,凭此查看) | ❌ 不填(B2B 不走载具,传了报错) | | **B2C / 手机条码载具(0)** | ❌ 不填 | ⚪ 可不填(票进载具) | ✅ 必填(类型 0 + 号码) | | **B2C / 自然人凭证载具(1)** | ❌ 不填 | ⚪ 可不填(票进载具) | ✅ 必填(类型 1 + 号码) | | **B2C / ezPay会员载具(2)** | ❌ 不填 | ✅ 必填(ezPay 规则:会员载具须带邮箱) | ✅ 必填(类型 2 + 号码) | > 记忆口诀: > - `B2B` → **统编 + 邮箱**必填,**不传载具**。 > - `B2C` → **载具必填**(0/1/2 三选一,不再支持"都不填");其中 ezPay会员载具(2) 还须带邮箱,其余两种载具邮箱留空。 > - 只要 `carrierType` 有值,`carrierNum` 就必须跟着填。 > - 已取消原「B2C 都不填 / 系统查看」模式(票无送达渠道、客户拿不到);原"ezPay 是否接受空 BuyerEmail"待办随之取消。 **③ 三种场景请求示例** B2B 公司发票(统编 + 邮箱): ```json { "orderId": 1001, "category": "B2B", "buyerName": "美食達有限公司", "buyerUbn": "12345678", "buyerEmail": "ms@example.com" } ``` B2C / 手机条码载具(载具 0,邮箱留空): ```json { "orderId": 1001, "category": "B2C", "buyerName": "王小明", "carrierType": "0", "carrierNum": "/ABC1234" } ``` B2C / ezPay会员载具(载具 2 + 邮箱): ```json { "orderId": 1001, "category": "B2C", "buyerName": "王小明", "carrierType": "2", "carrierNum": "C1597485444", "buyerEmail": "xm@example.com" } ``` **处理**:校验订单归属与可开票(门店 ezPay 已开通启用、非免用发票)→ 金额拆分 → 组装明细 → 调 `EzPay.issueInvoice` → 落库发票号/随机码/凭证。 **响应** `AjaxResult`: - 成功 `{code:200, msg:"开票成功", data:{invoiceNumber, randomNum, invoiceTransNo}}` - 失败 `{code:500, msg:"<可理解原因>"}`(统编格式非法 / 门店不可开票 / 已开票 / ezPay 失败原因) ### GET `/system/userOrder/getInvoice?orderId=` 客户查看自己订单的发票状态。 **响应** `AjaxResult` data:`{invoiceStatus, invoiceNumber, invoiceUrl, category, ...}`(不可开票门店返回 `invoiceStatus=null` 前端隐藏入口)。 --- ## 二、管理端(商家端 + 平台后台) `PosOrderInvoiceController`(`@RequestMapping("/system/orderInvoice")`,权限键 `chanting:orderInvoice:*`)。 ### GET `/system/orderInvoice/list` 订单发票列表(分页 + 筛选)。 **入参**(query):`orderId`/`orderNo`/`storeId`/`invoiceStatus`/`invoiceCategory`/日期范围 + `startPage()`。 **响应** `TableDataInfo`,行 `PosOrderInvoiceVo`:订单号、门店、发票类型、买方、发票号、状态、金额、开立/作废时间。 ### GET `/system/orderInvoice/{orderId}` 订单发票详情。 ### PUT `/system/orderInvoice/retry/{orderId}` 重试开票(仅 `invoiceStatus ∈ {2 失败, 3 作废}`)。权限 `chanting:orderInvoice:retry`。 ### PUT `/system/orderInvoice/invalid/{orderId}` 作废发票(仅 `invoiceStatus = 1 已开`)。权限 `chanting:orderInvoice:invalid`。入参可选 `{invalidReason}`。 --- ## 三、ezPay 外部调用(复用工具类,非新接口) | 业务 | 工具类方法 | ezPay 端点 | |------|-----------|-----------| | 开立 | `EzPay.issueInvoice(baseUrl, cfg, postData)` | `/Api/invoice_issue` (v1.5) | | 作废 | `EzPay.doPost(baseUrl + URL_INVALID, cfg, postData)` | `/Api/invoice_invalid` (v1.0) | `EzPayConfig` 由门店 `pos_store_ezpay` 的 `merchantId`/`hashKey`/`hashIv` 构造;`baseUrl` 测试 `BASE_TEST`、正式 `BASE_PROD`(按环境配置)。 ## 四、菜单 / 权限(写入 updatesql/sql.md) `sys_menu` 新增「订单发票管理」(C) + 按钮权限 `chanting:orderInvoice:list/query/retry/invalid`,挂在平台后台门店/订单相关父菜单下(参考 009 菜单写法)。 --- ## 五、捐赠发票 US6(LoveCode) ### GET `/system/userOrder/loveOrg/list`(客户端,挂 UserOrderController) 捐赠机构列表,供客户端渲染选择器、选中回填 loveCode。 **入参**(query):`keyword`(按 org_name/org_short_name 模糊搜索,可空)、`pageNum`/`pageSize`(分页,默认 1/20)。 **响应** `TableDataInfo`,行 `{loveCode, orgName, orgShortName}`,仅返回 `enabled=1`。 ### POST `/system/userOrder/applyInvoice`(捐赠场景) DTO `ApplyInvoiceDto` 新增字段: | 字段 | 类型 | 含义 | |------|------|------| | loveCode | String | 捐赠码(3-7 位数字)。category=DONATION 时必填 | **捐赠场景必填/互斥**: | 场景 | loveCode | carrierType/carrierNum | buyerUbn | buyerEmail | |------|----------|------------------------|----------|------------| | 捐赠 | ✅ 必填(3-7 位) | ❌ 必须空(与载具互斥) | ❌ 空 | ❌ 空 | 请求示例: ```json { "orderId": 1001, "category": "DONATION", "loveCode": "919" } ``` **处理**:校验 loveCode 格式 + 互斥 → 机构解析(local `pos_love_org` 命中取 orgName;未命中调 `EzPay.checkLoveCode`,IsExist=N 拒「捐赠码无效」)→ 组装 issue(ezPay Category=B2C、LoveCode、CarrierType 空、PrintFlag=N、BuyerName=orgName)→ 调 `EzPay.issueInvoice` → 落库 love_code/love_org_name + 发票号(码图三列为空)。 **响应**:成功 `{code:200, data:{invoiceNumber, loveOrgName}}`;失败 `{code:500, msg:"捐赠码无效/门店不可开票/ezPay 失败"}`。 ### ezPay checkLoveCode(US6 新增封装,复用工具类) | 业务 | 工具类方法 | ezPay 端点 | |------|-----------|-----------| | 捐赠码验真 | `EzPay.checkLoveCode(baseUrl, cfg, loveCode)` | `/Api_inv_application/checkLoveCode` (BDV v1.0) | 仅 local 未命中时调用;回 IsExist Y/N(不回机构名)。BDV 请求需 CheckValue(已知手册勘误,见 plan.md US6 设计决策 5)。 --- ## 六、客户端选择器对接说明(给客户端团队,US6) 客户在订单详情发起开票、选「捐赠发票」时的客户端对接流程: ### 1. 拉机构列表渲染选择器 用户进入捐赠开票 → 客户端调列表接口(带搜索关键词,远程分页): ``` GET /system/userOrder/loveOrg/list?keyword=創世&pageNum=1&pageSize=20 ``` 响应 `TableDataInfo`: ```json { "code": 200, "rows": [ { "loveCode": "919", "orgName": "財團法人創世社會福利基金會", "orgShortName": "創世基金會" } ], "total": 3 } ``` 客户端据此渲染一个**可搜索的下拉/列表**(输入关键词 → 调接口 → 显示 `orgShortName` 或 `orgName`);用户选中某机构 → 自动回填 `loveCode`。 ### 2. 提交捐赠开票 用户选好机构(或手输捐赠码)后,提交开票,**只带 loveCode,不带姓名/邮箱/载具/统编**: ``` POST /system/userOrder/applyInvoice { "orderId": 1001, "category": "DONATION", "loveCode": "919" } ``` 成功响应 `data.loveOrgName` 为捐赠机构名(本地命中取简称/全名,未命中且 ezPay 验真通过则为「未知机构」)。 ### 3. 交互要点 - **字段互斥**:选捐赠时载具/邮箱/统编输入框隐藏;后端校验若同时带 `carrierType` 与 `loveCode` 会拒。 - **捐赠码长度**:3-7 位数字(ezPay 限制);前端输入框 `maxlength=7`,格式即时校验 `^\d{3,7}$`。 - **手输兜底**:用户手输一个清单里没有的码,后端会调 ezPay `checkLoveCode` 验真(Y 才放行),机构名显示「未知机构」。 - **查询入口**:提供一个「查机构捐赠码 ↗」链接跳财政部平台 `https://www.einvoice.nat.gov.tw/portal/btc/btc603w`,让用户找不在列表里的机构。 - **开票后展示**:捐赠发票无条码/二维码(PrintFlag=N),客户端详情页显示「已捐赠给 {loveOrgName}」+ 捐赠码,不要渲染码图。 - **机构清单维护**:清单由后端 `pos_love_org` 表提供(财政部 CSV 导入),客户端无需关心同步。