# 发票模块完整 API 文档（管理端 + 小程序端）

> 发票功能：用户对已支付订单申请开票 → 管理员审核开票 → 用户下载电子发票。支持换开（最多3次）

---

## 一、状态流转

```
用户申请 → PENDING（待开票）
              ├─ 管理员开票完成 → ISSUED（已开票）
              └─ 管理员标记失败 → FAILED（开票失败）→ 管理员重新开票 → ISSUED
ISSUED → 用户换开 → 原发票 VOIDED（已作废）+ 新发票 PENDING
ISSUED → 管理员作废 → VOIDED
```

## 二、关联字典

| 字典 dict_type | 说明 | 枚举值 |
|---|---|---|
| `invoice_status` | 发票状态 | `PENDING`=待开票, `ISSUED`=已开票, `FAILED`=开票失败, `VOIDED`=已作废 |
| `invoice_title_type` | 抬头类型 | `PERSONAL`=个人, `COMPANY`=企业 |

## 三、通用响应结构 InvoiceVO

所有发票接口返回的数据结构（管理端直接返回 `InvoiceVO`，小程序端经 Feign 透传为 `Map<String, Object>`，字段相同）：

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | String | 发票ID |
| `orderId` | String | 订单ID |
| `orderNo` | String | 订单编号 |
| `amount` | String | 发票金额（元） |
| `titleType` | String | 抬头类型：`PERSONAL`=个人 / `COMPANY`=企业（字典 `invoice_title_type`） |
| `invoiceTitle` | String | 发票抬头（个人姓名或企业名称） |
| `taxNumber` | String | 纳税人识别号（企业发票有值） |
| `email` | String | 接收邮箱 |
| `invoiceType` | String | 发票类型，固定为"电子普通发票" |
| `invoiceContent` | String | 发票内容，固定为"明细" |
| `status` | String | 发票状态（字典 `invoice_status`） |
| `fileUrl` | String | 发票文件URL（已开票后有值） |
| `failReason` | String | 开票失败原因（失败时有值） |
| `contactPhone` | String | 联系电话 |
| `remark` | String | 备注信息 |
| `bankName` | String | 开户行（企业发票） |
| `bankAccount` | String | 银行账号（企业发票） |
| `companyAddress` | String | 企业地址（企业发票） |
| `companyPhone` | String | 企业电话（企业发票） |
| `invoiceNo` | String | 发票号码（已开票后有值） |
| `issuedAt` | String | 开票时间，格式 `yyyy-MM-dd HH:mm:ss` |
| `issuedBy` | String | 开票操作人（管理员ID） |
| `userId` | String | 申请用户ID |
| `originalInvoiceId` | String | 原发票ID（换开时有值，指向被作废的原发票） |
| `reissueCount` | Integer | 换开次数（0~3） |
| `createdAt` | String | 申请时间 |
| `updatedAt` | String | 更新时间 |

---

## 四、小程序端接口（C端用户）

> 网关路径前缀：`/mp/invoice`
> 需要用户登录（携带 Authorization: Bearer token）

### 4.1 申请开票

```
POST /mp/invoice/apply
```

**使用场景**：用户在订单详情页点击「申请发票」，填写开票信息后提交

**请求参数** (JSON Body)：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `orderId` | String | ✅ | 订单ID |
| `invoiceTitle` | String | ✅ | 发票抬头（个人姓名/企业名称），最长100字 |
| `titleType` | String | 否 | 抬头类型，默认 `PERSONAL`。可选：`PERSONAL`=个人, `COMPANY`=企业 |
| `taxNumber` | String | 企业必填 | 纳税人识别号，最长30位。`titleType=COMPANY` 时必填 |
| `email` | String | 否 | 接收邮箱（邮箱格式校验），最长100字 |
| `contactPhone` | String | 否 | 联系电话，最长20位 |
| `remark` | String | 否 | 备注信息，最长500字 |
| `bankName` | String | 否 | 开户行（企业发票选填），最长100字 |
| `bankAccount` | String | 否 | 银行账号（企业发票选填），最长30位 |
| `companyAddress` | String | 否 | 企业地址（企业发票选填），最长200字 |
| `companyPhone` | String | 否 | 企业电话（企业发票选填），最长20位 |

**响应**：`Result<InvoiceVO>`（见第三节）

**校验规则**：
- 订单必须属于当前用户
- 订单状态须为：已支付 / 已确认 / 待出发 / 旅行中 / 已完成 / 已付定金
- 同一订单不可重复申请（已有待开票或已开票的发票时拒绝）
- 开票金额自动取订单实付金额（`paidAmount`），如无则取总价（`totalPrice`）
- 发票类型固定「电子普通发票」，内容固定「明细」

**调用示例**：
```json
// 个人发票
{
  "orderId": "1902000000000001",
  "invoiceTitle": "张三",
  "titleType": "PERSONAL",
  "email": "zhangsan@example.com",
  "contactPhone": "13800138000"
}

// 企业发票
{
  "orderId": "1902000000000001",
  "invoiceTitle": "呼伦贝尔XX旅行社有限公司",
  "titleType": "COMPANY",
  "taxNumber": "91150000MA00000X00",
  "email": "finance@company.com",
  "bankName": "中国银行海拉尔支行",
  "bankAccount": "1234567890123456789",
  "companyAddress": "呼伦贝尔市海拉尔区XX路XX号",
  "companyPhone": "0470-1234567"
}
```

---

### 4.2 发票详情（按发票ID）

```
GET /mp/invoice/{id}
```

**使用场景**：从发票列表点进详情页

**路径参数**：

| 参数 | 类型 | 说明 |
|---|---|---|
| `id` | Long | 发票ID |

**响应**：`Result<InvoiceVO>`

**校验规则**：只能查看自己的发票

---

### 4.3 通过订单ID查询发票

```
GET /mp/invoice/order/{orderId}
```

**使用场景**：订单详情页判断是否已开票。返回 `data=null` 表示未开票，可显示「申请发票」按钮

**路径参数**：

| 参数 | 类型 | 说明 |
|---|---|---|
| `orderId` | Long | 订单ID |

**响应**：`Result<InvoiceVO>`（未开票时 `data` 为 `null`）

---

### 4.4 发票换开

```
POST /mp/invoice/{invoiceId}/reissue
```

**使用场景**：已开票后发现抬头/税号填错，点击「换开发票」修改信息重新开具

**路径参数**：

| 参数 | 类型 | 说明 |
|---|---|---|
| `invoiceId` | Long | 原发票ID |

**请求参数** (JSON Body)：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `invoiceTitle` | String | ✅ | 新发票抬头，2~100字 |
| `titleType` | String | ✅ | 新抬头类型：`PERSONAL` / `COMPANY` |
| `taxNumber` | String | 企业必填 | 新纳税人识别号 |
| `email` | String | 否 | 新接收邮箱 |

**响应**：`Result<InvoiceVO>`（返回新创建的发票记录，原发票自动作废）

**校验规则**：
- 只能对状态为 `ISSUED`（已开票）的发票换开
- 换开次数上限 **3次**（reissueCount >= 3 时拒绝）
- 企业抬头必须填税号
- 换开后：原发票状态变为 `VOIDED`，新发票状态为 `PENDING`，`originalInvoiceId` 指向原发票

**调用示例**：
```json
{
  "invoiceTitle": "呼伦贝尔XX旅行社有限公司",
  "titleType": "COMPANY",
  "taxNumber": "91150000MA00000X00",
  "email": "finance@company.com"
}
```

---

## 五、管理端接口（后台管理员）

> 网关路径前缀：`/admin/invoice`
> 需要管理员登录（携带 Admin-Token）

### 5.1 上传发票文件

```
POST /admin/invoice/upload
```

**使用场景**：管理员在开票前先上传发票PDF/图片文件

**请求参数** (form-data)：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file` | File | ✅ | 发票文件，支持 PDF / JPG / JPEG / PNG，最大 10MB |

**响应**：
```json
{
  "code": 200,
  "message": "success",
  "data": {
    "fileUrl": "https://oss.xxx.com/invoice/20260325/abc123.pdf"
  }
}
```

---

### 5.2 发票分页列表

```
GET /admin/invoice/page
```

**使用场景**：发票管理列表页，支持筛选

**请求参数** (Query)：

| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `status` | String | 否 | - | 按状态筛选（字典 `invoice_status`）：`PENDING` / `ISSUED` / `FAILED` / `VOIDED` |
| `orderNo` | String | 否 | - | 按订单编号模糊搜索 |
| `userId` | Long | 否 | - | 按用户ID筛选 |
| `page` | int | 否 | 1 | 页码（最小1） |
| `pageSize` | int | 否 | 20 | 每页条数（1~100） |

**响应**：
```json
{
  "code": 200,
  "message": "success",
  "data": {
    "records": [ InvoiceVO, ... ],
    "total": 100,
    "current": 1,
    "size": 20
  }
}
```

---

### 5.3 发票详情

```
GET /admin/invoice/{id}
```

**路径参数**：`id` — 发票ID

**响应**：`Result<InvoiceVO>`

---

### 5.4 开票完成

```
PUT /admin/invoice/{id}/issue
```

**使用场景**：管理员线下开票完成后，上传发票文件并填写发票号码，标记开票完成

**路径参数**：`id` — 发票ID

**请求参数** (JSON Body)：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `fileUrl` | String | ✅ | 发票文件URL（通过 5.1 上传接口获取） |
| `invoiceNo` | String | ✅ | 发票号码（12位数字） |

**响应**：`Result<InvoiceVO>`

**校验规则**：只有 `PENDING`（待开票）或 `FAILED`（开票失败）状态的发票可操作

**调用示例**：
```json
{
  "fileUrl": "https://oss.xxx.com/invoice/20260325/abc123.pdf",
  "invoiceNo": "012345678901"
}
```

---

### 5.5 开票失败

```
PUT /admin/invoice/{id}/fail
```

**使用场景**：发票信息有误，管理员标记失败并填写原因，用户可修正后重新申请

**路径参数**：`id` — 发票ID

**请求参数** (JSON Body)：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `failReason` | String | ✅ | 失败原因 |

**响应**：`Result<InvoiceVO>`

**校验规则**：只有 `PENDING`（待开票）状态的发票可操作

**调用示例**：
```json
{
  "failReason": "发票抬头与纳税人识别号不匹配，请核实后重新申请"
}
```

---

### 5.6 作废发票

```
PUT /admin/invoice/{id}/void
```

**使用场景**：管理员主动作废已开票的发票

**路径参数**：`id` — 发票ID

**请求参数**：无

**响应**：`Result<InvoiceVO>`

**校验规则**：已作废的发票不可重复作废

---

## 六、前端页面对接建议

### 小程序端页面

**订单详情页**：
- 调用 `GET /mp/invoice/order/{orderId}` 判断是否已开票
- `data=null` → 显示「申请发票」按钮（订单状态需满足条件）
- `data.status=PENDING` → 显示「开票中」
- `data.status=ISSUED` → 显示「查看发票」+「换开发票」按钮
- `data.status=FAILED` → 显示失败原因 + 「重新申请」按钮
- `data.status=VOIDED` → 显示「已作废」

**申请开票页**：
- 默认「个人」抬头，切换「企业」后展开额外字段（税号必填 + 开户行/银行账号/地址/电话选填）
- 金额自动显示（不可编辑，由后端计算）

**发票详情页**：
- 显示全部字段信息
- `status=ISSUED` 时显示发票文件下载链接（`fileUrl`）和发票号码（`invoiceNo`）
- `status=ISSUED` 且 `reissueCount < 3` 时显示「换开」按钮

### 管理端页面

**发票列表页**：
- 顶部筛选栏：状态下拉（全部/待开票/已开票/开票失败/已作废）+ 订单号搜索框
- 列表列：订单号、用户、金额、抬头、状态、申请时间、操作
- 操作按钮：
  - `PENDING` → 「开票」「标记失败」
  - `FAILED` → 「重新开票」
  - `ISSUED` → 「作废」
  - `VOIDED` → 无操作

**开票操作**：
1. 先调 `POST /admin/invoice/upload` 上传发票文件 → 拿到 `fileUrl`
2. 再调 `PUT /admin/invoice/{id}/issue` 传入 `fileUrl` + `invoiceNo`
