# 订单电子发票:全联式自动开票设计(010 演进)

**Feature**: 演进 `010-order-invoice`(订单 ezPay 电子发票开立)
**Created**: 2026-07-27
**Status**: Draft(待审)
**Supersedes**: 010 `spec.md` 中「客户在订单完成后手动申请开票」的**触发/入口模型**(改为下单捕获 + 出餐自动)。010 已建的 B2C/B2B/载具/捐赠/作废/重试/码值落库等开票核心**全部复用**,不在本期重做。

## 1. 背景与动机

010 已实现「客户在订单完成后进订单详情手动申请发票」。现按**全联福利中心(PX Mart)**模式演进为:**下单(结账)时选发票类型(默认个人纸本),商家出餐时自动开票**。目标:

- **零信息也能开**(默认个人纸本,BuyerName 取手机号),解决「客户没设载具怎么开」。
- 发票自动随订单流转,**纸本由骑手送餐时一并交付**。
- 会员有「**我的发票**」夹,App 内随时查看。
- **开发票门店每笔必开**;免用发票门店不开。

参考:全联福利中心 App、老板提供的纸本发票样张(标准台湾电子发票证明联:左右两组 QR 码 + 条码 + 发票号/卖方统编/买方/明细/随机码)。

## 2. 范围

**本期做(后端为主)**:

- 下单(`createOrder`)捕获发票意图
- 商家出餐(`dispatchOrder`)自动开票
- 个人纸本零信息兜底分支(补 010 缺的 B2C 无载具→PrintFlag=Y 分支)
- 载具校验(接入 `checkBarCode`)
- 会员发票夹列表接口(按 user_id)
- 发票信息双入口(结账时设 + 复用 014 `info_invoice` 作「我的发票抬头」管理)
- `info_invoice` 加「是否默认」标记

**本期不做(留增量)**:

- 客户端 App 的结账页发票选择 UI、我的发票夹 UI、我的发票抬头管理 UI(沿用 010 分工,客户端团队对接)
- POS 实体打印机对接(纸本证明联由商家自行打印,平台只生成码值)
- 发票折让、字轨管理、批次开立、中奖通知

## 3. 核心决策(已与需求方确认)

| # | 决策 |
|---|---|
| D1 | 触发时机 = 商家出餐 `dispatchOrder`(state 1→2)统一开所有档位 |
| D2 | 全联式 UX:发票类型在下单(结账)时选,**顶层只有 个人发票 / 公司发票 两种** |
| D3 | 个人发票默认 = 纸本(零输入);要载具才现填手機條碜/自然人凭证;**捐赠归在个人发票下**(非并列类型) |
| D4 | 个人(B2C)BuyerName = **用户手机号**(`InfoUser.phone`,自动取,客户不填) |
| D5 | 「同意设置为默认」复选框 + 「选用常用载具/抬头」:复用 014 `info_invoice` 存储,加「是否默认」标记;**只对载具与统编抬头生效,捐赠不存** |
| D6 | 发票信息双入口:① 结账时设 ②「我的发票抬头」管理页(014 后端已就绪) |
| D7 | 纸本(PrintFlag=Y)证明联**只打一次**,骑手送餐带上;发票夹只展示结构化信息,**不补打、不重渲染码图** |
| D8 | 载具/捐赠(PrintFlag=N)无纸本,发票夹标注「已存入载具/已捐赠」 |
| D9 | 货到付款:出餐照开(纸本要赶骑手),送达失败/取消 → 作废(复用 010 `invalid`) |
| D10 | 门店不可开票(`canInvoice=false`:免用发票或 ezPay 未开通/未启用)→ 结账页不展示发票选项,出餐跳过自动开票 |

## 4. 结账页发票信息架构(客户端 UI 参考)

```
发票类型:
  ○ 个人发票                      ← 默认选中
  │    └─ 这张个人发票怎么处理?
  │        ○ 纸本                  ← 默认,零输入,BuyerName=手机号,PrintFlag=Y
  │        ○ 存手機條碜载具          [填条码 / 选用常用] [☐ 同意设置为默认]
  │        ○ 存自然人凭证载具        [填凭证]
  │        ○ 捐赠给社福机构          [选机构 / 填捐赠码],PrintFlag=N
  │
  ○ 公司发票
       └─ 统一编号 [填] + 公司名称 [填],PrintFlag=Y (B2B)
```

ezPay 映射:

| 结账选择 | Category | 关键字段 | PrintFlag |
|---|---|---|---|
| 个人→纸本(默认) | B2C | BuyerName=手机号,无载具无捐赠 | Y |
| 个人→手機條碜 | B2C | CarrierType=0 + CarrierNum | N |
| 个人→自然人凭证 | B2C | CarrierType=1 + CarrierNum | N |
| 个人→捐赠 | B2C | LoveCode | N |
| 公司发票 | B2B | BuyerUBN + BuyerName(公司名) + BuyerEmail | Y |

> 010 现有 `ApplyInvoiceDto.DONATION` 独立类型可废弃:捐赠统一为 `B2C + loveCode`(代码 `buildIssueData` 已有此分支,见 010 T041)。

## 5. 架构与数据流

### 5.1 下单捕获(`createOrder`)

- `OrderCreateInput` 新增发票意图字段:`invoiceChoice`(PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、`carrierType`、`carrierNum`、`buyerUbn`、`buyerName`(公司名,仅 COMPANY)、`loveCode`、`saveAsDefault`(boolean)。
- `createOrder` 从 JWT 取 userId → 存订单时把发票意图写入 `pos_order_invoice` 意图行(`issue_triggered=0` 未触发，见 §7；方案B，不再落 pos_order)。
- 门店守卫:`canInvoice(mdId)==false` → 该单不写发票意图(等价不开票);客户端结账页按 `canInvoice` 接口隐藏发票选项。
- 载具/捐赠验真(见 §8):不通过则拦截下单。
- `saveAsDefault=true` → 将该载具/统编抬头写入 `info_invoice` 并标记为默认(其余取消默认)。

### 5.2 商家出餐自动开票(`dispatchOrder`,state 1→2)

- `PosOrderShOprateController.dispatchOrder` 在 `setState(2)` 之后调用 `OrderInvoiceService.autoIssue(orderId)`。
- `autoIssue` 流程:
  1. 读 `pos_order_invoice` 发票意图行(`issue_triggered=0`) + 门店 ezPay 凭证(`assertInvoiceable`，复用 010)。
  2. 门店不可开票 / 订单无发票意图 → **跳过**(不报错,不影响出餐)。
  3. 组装 `ApplyInvoiceDto`(意图 → category/载具/统编/捐赠);**个人 BuyerName = `InfoUser.phone`**。
  4. 复用 010 开票核心(`buildIssueData` + `ezPay.issueInvoice` + 落库)。**放宽两点**:不校验客户归属(系统触发)、不强制 `payStatus==1`(货到付款出餐时未付款也要开,见 D9)。
  5. PrintFlag=Y → 码值(BarCode/QRcodeL/R)落库,供商家打印证明联。
- 在意图行上落结果(复用 010)并置 `issue_triggered=1`，状态 已开 / 失败(失败由运营在后台重试，复用 010 retry)。

### 5.3 会员发票夹(我的发票)

- 新增 `GET /system/userOrder/myInvoices`(JWT,按 userId 分页)→ `pos_order_invoice i JOIN pos_order o ON i.order_id = o.id WHERE o.user_id = ?`。
- 返回字段裁剪:PrintFlag=Y → 结构化信息(发票号/随机码/金额/明细/门店)+「纸本已开」,**不返回码图、无补打**;PrintFlag=N → 结构化信息 +「已存入载具/已捐赠」。
- 客户端 UI 由客户端团队对接。

### 5.4 货到付款兜底(D9)

- 出餐照开(含货到付款)。若送达失败 / 订单取消(state→4)→ 调 `OrderInvoiceService.invalid(orderId)`(复用 010)作废发票。取消链路挂钩点见 §11。

## 6. 关键业务规则

- **零信息兜底**:个人纸本 = B2C + 无载具 + 无捐赠 → PrintFlag=Y(法定强制),BuyerName=`InfoUser.phone`。客户什么都不填也能开。**需补 010 缺的「B2C 无载具→PrintFlag=Y」分支**:现 `validateInvoiceInput` 强制 B2C 有载具(487–520 行),`buildIssueData` B2C 永远 PrintFlag=N(540–548 行)。
- **证明联一次为限**:财政部规定证明联以输出一次为限。ezPay 技术上不锁(码值可经 `invoice_search` 重复取),但**平台不提供补打 / 不重渲染码图**,守住商家合规线。会员纸本丢失 → 凭发票号 + 随机码兑奖。
- **PrintFlag 互斥**:载具/捐赠 PrintFlag=N,纸本/公司 PrintFlag=Y;载具不能再印纸本(防一票两份,ezPay 校验拦截)。
- **BuyerName**:个人 = 手机号;公司 = 公司名(客户填);捐赠 = 机构名(开票时 `resolveLoveOrg` 回填)。
- **金额**:发票金额 = `amount − freight`(不含运费,沿用 010 research D1)。

## 7. 数据模型变更(SQL 写 `updatesql/sql.md`,不直接执行)

- **方案B**:发票意图(下单捕获)落 `pos_order_invoice` 意图行(与开票结果同表,避免意图/结果双写);`pos_order` **不加** 发票列。
- `pos_order_invoice` 新增:`invoice_choice VARCHAR(16)`(下单意图 PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、`issue_triggered TINYINT NOT NULL DEFAULT 0`(是否已触发开票,与 invoice_status 独立:下单捕获=0、出餐开票执行过=1;管理端发票管理按 `issue_triggered=1` 筛选,排除未触发的意图行)。`buyer_ubn`/`carrier_type`/`carrier_num`/`love_code` 复用已有列。
- `info_invoice` 新增 `is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头'`(014 原「无默认」FR-006,现加默认标记支持 D5)。

## 8. 载具校验

| 输入 | 校验方式 | 接入点 |
|---|---|---|
| 手機條碜 | ezPay `checkBarCode`(IsExist Y/N) | 结账填入 / 存默认时 |
| 捐赠码 | ezPay `checkLoveCode`(010 已实现) | 结账填入(从开票前置到下单) |
| 自然人凭证 | 仅格式正则(`^[A-Z]{2}\d{14}$`),**无 API** | 结账填入 |
| 公司统编 | 格式正则(`^\d{8}$`) | 结账填入 |

- 新增 `EzPay.checkBarCode(baseUrl, cfg, barCode)` 方法(平行 `checkLoveCode`;`EzPayEncryptUtil` 的 CheckValue/CheckCode 已就绪;`URL_CHECK_BARCODE` 常量已定义)。
- `checkBarCode` 按门店凭证鉴权 → 结账验真用**该订单门店**的 ezPay 凭证。

## 9. 复用 010 / 009 / 014 的部分(不改)

- 010:`OrderInvoiceService` 开票核心(`buildIssueData`/issue/落库)、作废(`invalid`)、重试(`retry`);`PosOrderInvoice` 实体 + mapper + 码值字段(Phase 9)。
- 工具类:`EzPay`/`EzPayConfig`/`EzPayEncryptUtil`(`checkLoveCode` 已有,加 `checkBarCode`)。
- 009:门店 ezPay 凭证 + 免用发票标识(`canInvoice`/`assertInvoiceable`)。
- 014:`info_invoice` 表 + `InfoInvoiceController` CRUD(复用为默认载具/抬头存储 + 管理入口)。

## 10. 接口变更概览

- `OrderCreateInput`:加发票意图字段。
- `POST /system/userOrder/createOrder`:存发票意图到 `pos_order`;载具/捐赠验真;`saveAsDefault` 写 `info_invoice`。
- `PosOrderShOprateController.dispatchOrder`:出餐 `setState(2)` 后调 `autoIssue`。
- `OrderInvoiceService.autoIssue(orderId)`:新增(复用开票核心,放宽归属/payStatus 校验)。
- `OrderInvoiceService.validateInvoiceInput` / `buildIssueData`:放宽 B2C(允许无载具)、补「无载具→PrintFlag=Y 纸本」分支、个人 BuyerName 取 `InfoUser.phone`。
- `EzPay.checkBarCode`:新增。
- `GET /system/userOrder/myInvoices`:新增(发票夹列表)。
- `info_invoice` 默认标记:`InfoInvoiceController` 加设默认 / 取默认能力(或前端按 `is_default` 处理)。
- i18n:结账页发票选项文案属客户端 UI,由客户端团队;后端无新增面向用户文字(沿用 010 FR-012 分工)。

## 11. 开放问题 / 待确认

- **autoIssue 同步 vs 异步**:出餐接口同步调 ezPay 开票会增加出餐耗时(ezPay 响应通常 <2s,可接受);若担心阻塞出餐,改异步(线程池 / MQ)+ 失败由运营后台重试。**倾向同步**(简单,失败有 `pos_order_invoice.status=失败` 可重试),实现阶段定。
- **货到付款作废触发点**:订单取消(state→4)的入口需确认(商家取消 `PosOrderShOprateController` / 用户取消 `UserOrderController`),在该取消链路挂钩 `invalid`。实现阶段对齐现有取消入口。
- **`/applyInvoice` 手动入口去留**:自动开票后客户无需手动申请。倾向**保留**作为「自动开票失败时客户可手动重试」的兜底入口(或仅运营后台重试即可,移除客户手动入口)。实现阶段定。
- **多店合并下单(OrderParent)**:一次下多店,发票按**子订单(pos_order,每店一张)**各自开。`createOrder` 已拆子订单,发票意图随子订单存。实现阶段核实 parent/子订单归属与意图传递。

## 12. 成功标准

- 客户结账选「个人发票→纸本」(零输入)下单 → 商家出餐 → 自动开成个人纸本,码值落库,商家可打印证明联。
- 客户选「手機條碜」填码 → `checkBarCode` 验真通过 → 出餐自动开成载具发票(PrintFlag=N)。
- 门店免用发票 / ezPay 未开通 → 结账无发票选项,出餐不开票。
- 货到付款订单出餐照开;送达失败取消 → 发票作废。
- 会员在「我的发票」看到本平台开给他的所有发票(纸本看结构化信息、载具看「已存入载具」)。
- 开发票门店每笔订单 100% 自动开出(不依赖客户手动申请)。

## 13. 后续

本设计审通过后,按项目「追加需求直接更新现有 spec/plan/tasks」习惯,把本设计折叠进 `specs/010-order-invoice/` 的 `spec.md`(新增自动开票 user story / FR)、`plan.md`(本设计)、`tasks.md`(新增任务),再进入实现。
