# Implementation Plan: 订单 ezPay 电子发票开立

**Branch**: `test`（不新建分支，在当前分支开发） | **Date**: 2026-06-16 | **Spec**: [spec.md](./spec.md)

**Input**: Feature specification from `specs/010-order-invoice/spec.md`

## Summary

订单完成后客户在客户端主动申请电子发票（B2C 个人邮箱 / B2B 公司统编 / 电子发票载具），系统读取门店 ezPay 凭证（来自 009 的 `pos_store_ezpay`），复用已实现的 `EzPay.issueInvoice` 即时开立，将发票号 / 防伪随机码落库到新表 `pos_order_invoice`（与订单 1:1），客户可查看发票；支持开票失败重试与发票作废。金额按订单商品实付（`amount − freight`，已扣全部优惠、不含运费）拆分为销售额 / 税额（台湾营业税 5% 内含），逐商品明细取自订单 `food` JSON，运费不计入发票。客户端开票/查询 API 供客户端团队对接（客户端 UI 不在本期）；商家端、平台后台管理端查看/重试/作废，新增文字四语言 i18n。

## Technical Context

**Language/Version**: Java 17+（Spring Boot，若依 RuoYi 框架；`jakarta.servlet`、`HexFormat` 表明 JDK17+）

**Primary Dependencies**:
- 后端：Spring Boot、MyBatis-Plus（`@TableName`/`@TableId`/`LambdaQueryWrapper`）+ XML mapper、Apache HttpClient、fastjson2、hutool、若依通用（`AjaxResult`/`TableDataInfo`/`BaseController`/`@PreAuthorize`）
- 复用（不改内部）：`com.ruoyi.app.utils.ezPay.EzPay`（issueInvoice/doPost）、`EzPayConfig`、`ezPayCrypto.EzPayEncryptUtil`
- 复用 009：`PosStoreEzpay`（门店 ezPay 凭证：merchantId/hashKey/hashIv/companyId、ezpayStatus、isEnabled）、`PosStoreEzpayMapper`
- 复用订单：`PosOrder`（id/ddId/amount/foodAmount/state/payStatus/food 等）、`UserOrderController`（客户端订单 `/system/userOrder`）
- 前端：Vue.js + Element UI（平台后台 `foodie-admin-vue`、商家端 `foodie-store`，vue-i18n 四语言）；客户端前端 UI 不在本期，本期交付开票/查询后端 API 供客户端团队对接

**Storage**: MySQL（新增表 `pos_order_invoice`，与 `pos_order` 1:1）

**Testing**: 轻量单测（开票金额含税拆分 + ezPay 回应判读，mock `EzPay.issueInvoice`）+ 手测（`cinv.ezpay.com.tw` 测试环境真凭证开票 / 作废）

**Target Platform**: Windows 开发 / Linux 部署的 Spring Boot 服务 + Vue 后台/商家端 + 客户端

**Project Type**: web-service（Spring Boot 后端）+ 多个 Vue / 小程序前端

**Performance Goals**: 开票调用（含 ezPay 网络往返）< 10s 超时；订单详情接口携带发票状态无显著额外开销

**Constraints**: 复用 ezPay 工具类不改其内部；不改订单状态机（订单「完成」由现有 `state=3` 判定）；发票作废以 ezPay `invoice_invalid` 接口返回为准，不在本地做时限计算；本期不含折让 / 字轨 / 批次 / 中奖通知

**Scale/Scope**: 1 新表；后端 1 domain + 1 mapper(含XML) + 1 service + 2 controller 域（客户端开票 + 管理端查看/重试/作废）；管理端两前端（平台后台 + 商家端）最小入口；4 语言 i18n ×2 前端

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*

`.specify/memory/constitution.md` 为未填充模板（占位符未替换），项目未定义具体宪法原则与门槛。**无实质门槛需评估**，设计遵循项目既有约定（若依分层、MyBatis-Plus、全栈字段清单、四语言 i18n、SQL 写 `updatesql/sql.md`）。Phase 1 后复核：无违反。

## Project Structure

### Documentation (this feature)

```text
specs/010-order-invoice/
├── spec.md              # 需求规格
├── plan.md              # 本文件（技术方案）
├── research.md          # Phase 0：关键技术决策与依据
├── data-model.md        # Phase 1：表结构、字段、状态机
├── quickstart.md        # Phase 1：跑通验证步骤
├── contracts/
│   └── api.md           # Phase 1：REST 接口契约
├── checklists/
│   └── requirements.md  # spec 质量检查
└── tasks.md             # /speckit-tasks 生成（本期 plan 不创建）
```

### Source Code (repository root)

```text
# ===== 后端 foodie_server =====
ruoyi-system/src/main/java/com/ruoyi/system/
├── domain/
│   ├── PosOrderInvoice.java                 # 新增：订单发票实体（与 pos_order 1:1）
│   ├── dto/ApplyInvoiceDto.java             # 新增：客户申请开票入参
│   └── vo/PosOrderInvoiceVo.java            # 新增：发票列表/详情 VO
├── mapper/
│   └── PosOrderInvoiceMapper.java           # 新增
└── resources/mapper/chanting/
    └── PosOrderInvoiceMapper.xml            # 新增

ruoyi-admin/src/main/java/com/ruoyi/app/
├── order/
│   ├── OrderInvoiceService.java             # 新增：开票/作废/查询业务（注入 EzPay + 订单 + 009凭证）
│   └── UserOrderController.java             # 已有：新增客户端开票接口 /applyInvoice、/getInvoice
├── mendian/
│   └── PosOrderInvoiceController.java       # 新增：管理端（商家+平台）查看/重试/作废 /system/orderInvoice
└── utils/ezPay/                              # 已有，不改内部：EzPay/EzPayConfig/EzPayEncryptUtil

# ===== SQL =====
updatesql/sql.md                             # 追加建 pos_order_invoice 表

# ===== 平台后台前端 foodie-admin-vue =====
src/api/chanting/orderInvoice.js             # 新增：接口封装
src/views/mendian/orderInvoice/index.vue     # 新增：订单发票管理（查看/重试/作废）
src/api/language/{zh_CN,zh_TW,en_US,vi}.js   # 四语言加 orderInvoice:{} key

# ===== 商家端前端 foodie-store =====
门店订单详情                                 # 显示发票状态/号
src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
```

**Structure Decision**:
- 后端遵循既有若依分层：`domain/mapper` 放 `ruoyi-system`（被各处依赖）；**开票业务 service 放 `ruoyi-admin.app.order.OrderInvoiceService`**，因为它要调用 `EzPay`（在 ruoyi-admin）+ 读订单 + 读 009 凭证——与 009「ezPay 联网放 Controller、Service 仅持久化」不同，本期开票业务较重，单独 service 更内聚，调用链：Controller → OrderInvoiceService → EzPay.issueInvoice。
- 客户端开票接口挂在既有 `UserOrderController`（`/system/userOrder`）下，与订单详情同域；管理端（商家+平台）查看/重试/作废用独立 `PosOrderInvoiceController`（`/system/orderInvoice`，权限键 `chanting:orderInvoice:*`）。
- 发票与订单 1:1：`pos_order_invoice` 当前态表（`uk_order_id` 唯一约束防重复开票），作废后状态置「作废」允许重开（不开历史明细表，作废记录保留在同一行）。
- 管理端两前端（平台后台 + 商家端）各自最小入口，严格四语言 i18n；客户端 UI 不在本期。

## Complexity Tracking

> 无宪法违反项，无需填写。

| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |

## Enhancement: 发票凭证补全与平台后台展示 (2026-06-22)

**背景**：开票成功后 `invoice_url` 实际存的是 ezPay `InvoiceTransNo`（交易流水号），字段名误导（被当成查看链接）；且 research.md D9 原计划落库的 `BarCode`/`QRcodeL`/`QRcodeR`（发票码图凭证）代码漏存，平台后台详情看不到发票凭证。领域概念澄清见 memory `reference-tw-einvoice-concepts`。

**设计决策**（经多轮确认）：
1. **字段改名**：`invoice_url` → `invoice_trans_no`（语义=ezPay 交易流水号），新增 `invoice_bar_code`/`invoice_qrcode_l`/`invoice_qrcode_r` 三列存发票码值字符串。
2. **开票落库**：`PrintFlag=Y`（B2B / B2C无载具 / B2C邮箱）时 ezPay issue 回应含 `BarCode`/`QRcodeL`/`QRcodeR`，开票成功一并落库；`PrintFlag=N`（载具发票）这 3 字段为空（合规——载具发票法定不能索取纸本）。
3. **search 对外接口不做**：码值开票时已落库，平台后台直接读库；载具发票核对走文案提示（凭发票号+随机码在 ezPay/财政部平台核对）。
4. **平台后台展示**（`foodie-admin-vue/views/mendian/orderInvoice/index.vue` 详情弹窗）：
   - 有码值：前端库（qrcode + jsbarcode）渲染左右二维码 + 条码，加「下载 PNG / 打印」按钮（html2canvas / window.print）
   - 载具发票（码值为空）：展示结构化信息 + 「已存入买受人载具，无纸本凭证」提示
5. **合规红线不动**：不改 PrintFlag 规则、载具与纸本互斥、防一票两份；仅补落库字段与展示。

**业务事实约束**：ezPay 不返回查看 URL 也不返回图片，只给码值字符串；`BarCode`/`QRcodeL`/`QRcodeR` 仅 `PrintFlag=Y` 返回（手册 EZP_INVI 第828-850行）。

## Enhancement: 捐赠发票(LoveCode) US6 (2026-07-20)

**背景**：spec.md 新增 US6——客户开票可选「捐赠发票」捐给社福机构。约束详见 memory `reference-lovecode-donation`：ezPay `LoveCode` 限 3-7 位（Int(7)，8 位被 CLC10002 拒）、`checkLoveCode` 只回 IsExist Y/N 不回机构名、捐赠 `PrintFlag=N` 无码图、载具与捐赠互斥。机构清单来自财政部 CSV（已转 `updatesql/pos_love_org.sql`，2016 行）。

**设计决策**：

1. **机构清单 = 手动执行 SQL**（不做上传接口/定时拉取）：`pos_love_org` 表 DDL + 2016 行 INSERT 在 `updatesql/pos_love_org.sql`，开发者直接 MySQL 执行；CSV 更新后重跑 `specs/010-order-invoice/gen_love_org_sql.py` 重新生成。>7 位码导入时 `enabled=0`（本期数据无此类）。

2. **pos_order_invoice 扩展两列**（ALTER 写 `updatesql/sql.md`）：`love_code VARCHAR(10)`（捐赠码，开捐赠发票时填）、`love_org_name VARCHAR(255)`（捐赠机构名，开票时从 `pos_love_org` 快照，便于历史展示）。捐赠发票 `invoice_category` 仍为 `B2C`（ezPay 捐赠 = B2C + LoveCode），靠 `love_code` 非空区分捐赠与普通 B2C。

3. **捐赠机构列表接口（客户端）**：`GET /system/userOrder/loveOrg/list?keyword=&pageNum=&pageSize=` → `TableDataInfo` of `{loveCode, orgName, orgShortName}`，仅返回 `enabled=1`，按 `org_name`/`org_short_name` 模糊搜索。挂 `UserOrderController`（客户端 JWT 鉴权）。客户端据此渲染选择器、选中回填 `loveCode`。

4. **applyInvoice 捐赠分支**：`ApplyInvoiceDto` 加 `loveCode` 字段。当 `category=B2C` 且 `loveCode` 非空 → 捐赠分支：
   - 校验：`loveCode` 3-7 位数字；`carrierType`/`carrierNum`/`buyerUbn` 必须空（互斥）。
   - 机构解析（local-first）：先查 `pos_love_org`(love_code, enabled=1) → 命中取 `org_name` 作 `love_org_name`；未命中 → 调 `EzPay.checkLoveCode` 验真（IsExist=Y 继续、`love_org_name`="未知"；IsExist=N 拒「捐赠码无效」）。
   - 组装 issue：`Category=B2C`、`LoveCode`=码、`CarrierType`=""、`PrintFlag=N`、`BuyerName`=orgName 或「愛心捐贈」、不传 `BuyerEmail`。
   - 开票成功落库：`love_code`+`love_org_name`+发票号；码图三列（bar_code/qrcode_l/r）为空（PrintFlag=N）。

5. **checkLoveCode 接入**：新增 `EzPay.checkLoveCode(baseUrl, cfg, loveCode)` 封装 BDV `/Api_inv_application/checkLoveCode`。**已知坑**：BDV 请求需 CheckValue，官方手册 CheckValue 公式有勘误（附件三把回应 CheckCode 误标成 CheckValue），按「HashKey 在前」实现，测试环境若校验失败改「HashIV 在前」重试（见 memory `reference-ezpay-invoice-api`）。仅作 local 未命中的兜底；MVP 可先只走本地清单（2016 家覆盖绝大多数），checkLoveCode 作 P2 兜底。

6. **捐赠发票展示**（管理端 + 客户端，客户端 UI 由客户端团队）：捐赠发票码图为空（PrintFlag=N），不能复用个人发票条码模板；详情展示结构化信息 + 「已捐赠给 {love_org_name}」+ 捐赠码。管理端 `orderInvoice/index.vue` 详情对捐赠发票走「无码图」分支（复用已有载具发票的同类分支）。

**范围边界**（同 010 既有约定）：交付后端（机构列表接口 + applyInvoice 捐赠分支 + checkLoveCode）+ 管理端展示；客户端选择器 UI 由客户端团队消费接口实现。

**文件改动**：
- 后端 entity：`PosLoveOrg`（新）、`PosOrderInvoice` 加 loveCode/loveOrgName、`ApplyInvoiceDto` 加 loveCode、`PosOrderInvoiceVo` 加 loveCode/loveOrgName
- 后端 mapper：`PosLoveOrgMapper`(+XML)、`PosOrderInvoiceMapper.xml` 加两列
- 后端 service/controller：`OrderInvoiceService`（捐赠分支 + loveOrg 列表 + checkLoveCode 接入）、`UserOrderController` 加 `/loveOrg/list`、`EzPay.checkLoveCode`
- SQL：`updatesql/sql.md` 加 pos_order_invoice ALTER 两列（pos_love_org 已在 pos_love_org.sql）
- 前端：平台后台 `orderInvoice/index.vue` 捐赠展示 + i18n；商家端订单详情捐赠标识 + i18n

## Enhancement: 全联式自动开票 (2026-07-27)

**背景**:010 现为「客户订单完成后手动申请开票」。按全联福利中心(PX Mart)模式演进为「下单选发票类型(默认个人纸本)+ 出餐自动开 + 会员发票夹」。完整设计见 [`auto-invoice-design.md`](./auto-invoice-design.md)(已与需求方多轮确认通过)。

**设计决策摘要**(详见 auto-invoice-design.md):

1. **触发**:商家出餐 `dispatchOrder`(state 1→2)统一开所有档位(纸本赶得上骑手取餐)。
2. **入口/IA**:下单(结账)时选,顶层 个人发票 / 公司发票 两种;个人默认子项=纸本(零输入),捐赠归在个人发票下(B2C + LoveCode)。
3. **零信息兜底**:补 010 缺的「B2C 无载具→PrintFlag=Y 纸本」分支(现 `validateInvoiceInput` 强制 B2C 有载具);个人 BuyerName=`InfoUser.phone`。
4. **载具校验**:新增 `EzPay.checkBarCode`(平行 checkLoveCode;EzPayEncryptUtil/`URL_CHECK_BARCODE` 已就绪),结账验真用门店凭证;捐赠码 checkLoveCode 前置到下单。
5. **证明联一次为限**:平台不补打/不重渲染码图;发票夹纸本只展示结构化信息(发票号+随机码+明细)。
6. **货到付款**:出餐照开,订单取消链路挂 `invalid`。
7. **会员发票夹**:新增 `GET /system/userOrder/myInvoices`(按 user_id JOIN pos_order)。
8. **双入口/默认**:`info_invoice` 加 `is_default`;复用 014 CRUD,全联式不建单独管理页。

**数据模型**(SQL 写 `updatesql/sql.md`,方案B):`pos_order_invoice` 加 `invoice_choice`(下单意图)+ `issue_triggered`(是否已触发开票,与 invoice_status 独立);`pos_order` 不再加发票列;`info_invoice` 加 `is_default`。

**文件改动**:`OrderCreateInput` + `createOrder`;`PosOrder`(+mapper);`OrderInvoiceService`(`autoIssue` + 补纸本分支 + BuyerName 取手机号 + 放宽校验);`PosOrderShOprateController.dispatchOrder` 挂 `autoIssue`;取消链路挂 `invalid`;`EzPay.checkBarCode`;`UserOrderController` 加 `myInvoices`;`InfoInvoiceController` 默认能力;SQL。

**开放问题**(实现阶段定):autoIssue 同步/异步;货到付款作废触发点对齐;`/applyInvoice` 手动入口去留;多店合并下单子订单发票归属。

**范围边界**:交付后端 + 管理端展示;客户端 App 的结账页/发票夹/抬头管理 UI 由客户端团队(沿用 010 分工)。POS 实体打印不做(商家自打证明联)。

