# 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 导入），客户端无需关心同步。
