# 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`=公司发票（开给营业人，凭统编报账） |
| 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 会员载具填会员账号 |

**② 什么时候必填（按场景）**

| 场景 | buyerUbn | buyerEmail | carrierType + carrierNum |
|------|----------|------------|--------------------------|
| **B2B 公司发票** | ✅ 必填（8 位数字） | ⚪ 可不填（B2B 默认打印，填了不生效） | ⚪ 可不填（B2B 不走载具） |
| **B2C 个人 / 邮箱接收** | ❌ 不填 | ✅ 填（发邮件） | ❌ 不填 |
| **B2C 个人 / 用载具** | ❌ 不填 | ⚪ 可不填（走载具就不发邮箱） | ✅ 必填（类型 + 号码都要） |
| **B2C 个人 / 系统查看** | ❌ 不填 | ❌ 不填 | ❌ 不填（PrintFlag=Y，靠发票号在系统查） |

> 记忆口诀：
> - `B2B` → 只要 **统编 buyerUbn**，其余（邮箱/载具）不用管。
> - `B2C` → 邮箱 / 载具 / 都不填 **三选一**：填邮箱→发邮件；填载具→存载具；都不填→客户在系统查看发票（PrintFlag=Y）。
> - 只要 `carrierType` 有值，`carrierNum` 就必须跟着填，否则报"载具号码不能为空"。
> - ⚠️ 「系统查看」模式依赖 ezPay 接受空 BuyerEmail，上线前需在测试环境验证；若 ezPay 拒绝则回落为必填邮箱。

**③ 四种场景请求示例**

B2B 公司发票：
```json
{ "orderId": 1001, "category": "B2B", "buyerName": "美食達有限公司", "buyerUbn": "12345678" }
```

B2C 个人 / 邮箱接收：
```json
{ "orderId": 1001, "category": "B2C", "buyerName": "王小明", "buyerEmail": "xm@example.com" }
```

B2C 个人 / 手机条码载具：
```json
{ "orderId": 1001, "category": "B2C", "buyerName": "王小明", "carrierType": "0", "carrierNum": "/ABC1234" }
```

B2C 个人 / 系统查看（不填邮箱、不用载具）：
```json
{ "orderId": 1001, "category": "B2C", "buyerName": "王小明" }
```

**处理**：校验订单归属与可开票（门店 ezPay 已开通启用、非免用发票）→ 金额拆分 → 组装明细 → 调 `EzPay.issueInvoice` → 落库发票号/随机码/凭证。

**响应** `AjaxResult`：
- 成功 `{code:200, msg:"开票成功", data:{invoiceNumber, invoiceUrl}}`
- 失败 `{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 菜单写法）。
