# Phase 0 Research: 订单 ezPay 电子发票开立

**Date**: 2026-06-16

记录本期关键技术决策与依据。所有决策基于已落地的 009、已验证的 ezPay API 规格（见记忆 `[ezPay电子发票API规格]`）与订单现状调研。

---

## D1. 开票金额含税拆分

**Decision**: 发票金额**不含运费**（运费由用户承担、非商家商品收入）。订单金额构成（已确认）：`amount = foodAmount − 各项优惠 + freight`，故商家开票口径取商品实付：

- `invoiceTotal` = `amount − freight`（= 商品实付，已扣全部优惠、不含运费）
- `TotalAmt` = `invoiceTotal`（含税总额）
- `sales_amt`（销售额，未税）= `round(invoiceTotal / 1.05)`（四舍五入到元）
- `tax_amt`（税额）= `invoiceTotal − sales_amt`

ezPay `Amt`=销售额、`TaxAmt`=税额、`TotalAmt`=含税总额，三者满足 `Amt + TaxAmt = TotalAmt`。

**Rationale**: 台湾餐饮 B2C 为含税定价（标价即含税）；ezPay B2C 规则「单价含税」。`pos_order` 无独立税额字段，`amount` 是客户实付金额，开票以实付为准。

**Alternatives**:
- 视 amount 为未税、另算税额：与「客户实付 = amount」语义冲突，会导致发票总额 ≠ 实付，不采用。
- 新增订单税额字段：改动面大且本期不改订单表，不采用。

---

## D2. 逐商品明细来源（无 PosOrderItem 表）

**Decision**: 订单商品明细从 `pos_order.food`（JSON 数组，fastjson）解析，每个元素映射 ezPay 多品项字段（`|` 分隔）：

| food 元素字段 | ezPay 字段 | 说明 |
|---------------|-----------|------|
| 名称字段（name/title，实现时确认） | `ItemName` | 商品名，多品项用 `\|` 连接 |
| `price` + `otherPrice` | `ItemPrice` | 含税单价 = 基础价 + 加料价（B2C 含税） |
| `number` | `ItemCount` | 数量 |
| (price+otherPrice)×number | `ItemAmt` | 该商品小计 |
| 固定「個」/「份」 | `ItemUnit` | 单位 |

**Rationale**: 调研确认 `pos_order` 无独立明细表，`food` 是唯一明细来源；`OrderService`/`PosOrderController` 已有 `JSONArray.parseArray(getFood())` 解析模式可复用。商品名称字段在调研样本（佣金计算、列表）中未取，实现时确认（预期 `name`/`title`/`foodName` 之一）。

**Alternatives**: 新建 `pos_order_item` 明细表并迁移 food：改动面过大、与现有大量 food 解析代码冲突，本期不做。

---

## D3. 优惠在发票商品明细上的体现（运费已排除）

**Decision**: 发票 `TotalAmt = amount − freight`（见 D1，已扣全部优惠、不含运费）。但逐商品明细按 `food` 原价列出时 `Σ 商品小计 = foodAmount > TotalAmt`，差额 = 各项优惠之和。为使发票商品明细与总额自洽，二选一（实现时定，**优先方案 B**）：

- **方案 A（折扣负项）**：商品按原价列，追加一条 `ItemName="折扣"`、`ItemAmt=-(优惠之和)` 的负项 → `Σ = TotalAmt`。需 ezPay 接受负金额。
- **方案 B（按比例分摊，推荐）**：把优惠按各商品金额占比分摊、调低各商品 `ItemPrice`/`ItemAmt`，使 `ΣItemAmt` 自然 = `TotalAmt`，无负项、ezPay 必接受；末项兜底吸收舍入差。

运费**不**出现在发票任何明细。

**Rationale**: ezPay 校验「商品小计 = 数量 × 单价」（逐商品）+ `Amt/TaxAmt/TotalAmt` 三栏自洽。方案 B 无负金额风险、最稳妥。

**⚠️ 待测试确认**: 方案 A 的负金额 ItemAmt 是否被 ezPay 接受（memory 规格未明确）。即便如此，方案 B 已足够，方案 A 仅作备选。

---

## D4. ezPay 开票参数映射（按场景）

| 场景 | Category | PrintFlag | BuyerUBN | CarrierType/Num | LoveCode |
|------|----------|-----------|----------|-----------------|----------|
| B2C 个人-邮箱 | B2C | Y | 空 | 空 | 空 |
| B2C 个人-载具 | B2C | N | 空 | 0/1/2 + 号码 | 空 |
| B2B 公司 | B2B | Y | 统编(8码) | 空 | 空 |

固定项：`Status=1`（即时开立，由 `EzPay.issueInvoice` 自动补）、`TaxType=1`（应税）、`TaxRate=5`、`MerchantOrderNo=订单 ddId`（同商店唯一）。

**Rationale**: 来自 ezPay INVI 规格（记忆）。PrintFlag 规则：无载具/捐赠时必 Y；B2B 必 Y。本期不做捐赠 → LoveCode 恒空。

---

## D5. 防重复开票（并发安全）

**Decision**: 
1. `pos_order_invoice` 设 `UNIQUE KEY uk_order_id (order_id)` —— 物理保证一笔订单至多一行当前发票。
2. 开票流程在事务内：`SELECT 当前行 → 校验状态(仅 未开/失败/作废 可开) → 调 ezPay → 写结果`。
3. 状态为「已开(1)」时接口直接拒绝（FR-005）。

**Rationale**: 客户可能并发点击「申请发票」；DB 唯一约束兜底 + 状态前置校验，避免产生重复发票（SC-004）。

**Alternatives**: 分布式锁：过度设计，单库唯一约束足够。

---

## D6. 作废与重开

**Decision**:
- 作废：状态为「已开」时调 `EzPay` 走 `invoice_invalid`（`URL_INVALID`），入参 `InvoiceNumber` + `InvalidReason`；以 ezPay 返回为准判定成功/不可作废（SC 不做本地时限计算）。
- 重开：作废成功后，同一行 `invoice_status` 置「作废(3)」；客户/运营再次开票时，先校验当前状态 ∈ {未开,失败,作废}，将该行重置为未开后重新开立 → 写入新 `invoice_number`（旧号覆盖，不保留历史明细，MVP 范围）。

**Rationale**: spec「一笔订单对应一张有效发票（作废后可重开）」。1:1 当前态表 + 状态机即可满足，无需历史明细表。

---

## D7. 开票业务层位置（架构）

**Decision**: 开票 service 放 `ruoyi-admin.app.order.OrderInvoiceService`（非 ruoyi-system），domain/mapper 放 ruoyi-system。

**Rationale**: 开票需调用 `EzPay`（位于 ruoyi-admin），若 service 在 ruoyi-system 会反向依赖 ruoyi-admin（与 009 让 ezPay 验证留在 Controller 同理）。本期开票业务（金额拆分+明细组装+ezPay 调用+落库）较重，单独 service 比 009「Controller 直接调」更内聚、可单测。

---

## D8. 客户端开票入口归属校验

**Decision**: 客户端 `/applyInvoice` 校验 `PosOrder.userId == 当前登录客户 id`，否则拒绝；门店不可开票（免用发票 / ezPay 未开通未启用）时返回明确提示、不显示入口。

**Rationale**: FR-006 + 安全（客户只能为自己订单开票）。可开票判定复用 009 的 `pos_store_ezpay` 状态。

---

## D9. ezPay 开票回应判读

**Decision**: `EzPay.issueInvoice` 回应 JSON：
- `Status == "SUCCESS"` 且 `Result.InvoiceNumber` 非空 → 成功，取 `InvoiceNumber`/`RandomNum`/`BarCode`/`QRcodeL`/`QRcodeR` 落库；
- 否则（`Status != SUCCESS` 或含 KEY1/INV/LIB 错误前缀）→ 失败，记 `Message` 到 `fail_reason`，状态置失败，订单不变。

**Rationale**: 与 009 凭证验证（看 KEY1）不同，开立以 `Status=SUCCESS` 为准。错误前缀见记忆规格（KEY1 加解密 / INV 发票业务 / LIB 重复状态 / NOR 网络）。

## D10. 管理后台「发票明细」展示范围（不复制 ezPay 控制台）

**Decision**: 后台发票明细页**只展示「开票成功已落库信息 + 订单信息」**，不复制截图（ezPay 商家后台控制台）的完整布局。展示字段集：

| 区块 | 字段 | 来源 |
|------|------|------|
| 发票核心 | 发票号码、防伪随机码、发票种类(B2B/B2C)、开立时间、发票状态 | `pos_order_invoice` |
| 买方 | 买方名称、买方统编(B2B)、接收邮箱、载具类型+号码 | `pos_order_invoice` |
| 金额 | 销售额、税额、含税总额 | `pos_order_invoice` |
| 商品明细 | 品名、数量、单位、单价、小计 | 开票时存 `ItemDetail` JSON 或重新解析 `pos_order.food` |

**不展示**（截图有、本期不做）：卖方营业名称/统编/会员编号、上传状态、中奖注记、目前可折让金额、折让单号、通知表、BarCode/QRcode、Kiosk列印、报关标记、发票备注。

**Rationale**:
- 截图是 **ezPay 自己的商家后台控制台**，服务于营业人管理发票全生命周期（折让/中奖/上传/通知），信息密度对我们平台运营是噪音。
- 卖方信息冗余：运营按订单/门店 scoped 进明细页，卖方上下文已知。
- 上传状态/中奖/折让/通知：要么 `invoice_search` 不返回（中奖/可折让/通知），要么低价值（上传状态隔日才更新），要么需额外基础设施。
- 结果：**明细页纯本地查询，不调 `invoice_search`**，秒开、不依赖 ezPay 可用性。

**Alternatives**: 完整复制控制台 → 需调 `invoice_search` + 折让/中奖额外接口，且多个关键字段拿不到，过度设计，不采用。

**已定（暂不存 ItemDetail）**：当前明细页（`selectInvoiceDetail` → `PosOrderInvoiceVo`）不展示逐商品行项，故无需存。日后若明细页要展示商品明细，再考虑开票时存一份（避免优惠按比例分摊后、行项与总额对不上）。

---

## D11. ezPay 不返回发票图/URL；`invoice_url` 是空壳字段

**Finding**: 核实官方手册 `EZP_INVI_1.2.1`——开立(`invoice_issue`)与查询(`invoice_search`)回应**均不含任何发票图片/PDF/URL 字段**。开立回应实际只返回：

```
MerchantID / InvoiceTransNo / MerchantOrderNo / TotalAmt / InvoiceNumber / RandomNum
CreateTime / CheckCode / BarCode / QRcodeL / QRcodeR   （后三个仅 PrintFlag=Y 时返回）
```

**Consequence & 处置（2026-06-22 已落地）**：原 `invoice_url` 确属空壳（ezPay issue/search 回应均不返回 URL/图片）。团队已按「改存 ezPay 真返回码值」处理：`invoice_url` 改名为 `invoice_trans_no`(InvoiceTransNo)，新增 `invoice_bar_code`/`invoice_qrcode_l`/`invoice_qrcode_r`（PrintFlag=Y 时 ezPay 返回的 BarCode/QRcodeL/R）。已核实实体 `PosOrderInvoice` + `OrderInvoiceService.applyInvoice`/`reIssueAndSave` 均从 issue 回应取值落库；`data-model.md` 已同步。明细页「凭证/链接」一行 → 改展示发票号/随机码/交易流水号。

**客户查看发票的三条路径**（能否给可点链接见 D12，能否打印见 D13）：

| 方式 | 机制 | 我们能否给可点链接 |
|------|------|--------------------|
| 邮箱（无载具） | 开票填 `BuyerEmail` → ezPay 自动发通知邮件，发票在邮件里 | ❌ 邮件由 ezPay 发、链接用平台密钥，复刻不了（D12） |
| 载具 | 选手机条码/自然人凭证/ezPay会员载具 → 票存进载具 → 客户自己去财政部/ezPay 查（见「附：载具概念」） | ❌ 票在客户账户，我们只存发票号 |
| 都不填（PrintFlag=Y） | 无自动送达渠道，票只在 ezPay/财政部平台，商家需手动打印交付 | ❌ 且外卖场景建议禁用此模式（见下） |

**⚠️ 「都不填」模式建议禁用**：客户远程收餐无法当面递纸票，而该模式 ezPay 不发邮件、不进载具，票「悬空」、客户拿不到。建议 B2C 开票强制「邮箱 或 载具 至少填一个」，顺带消除 `contracts/api.md` 那条「ezPay 是否接受空 BuyerEmail」的待验证项。B2B 不受影响（PrintFlag=Y、靠统编报账）。

> 订正：本节早先版本写过「均无静态链接可存」，过于绝对——准确结论见 D12（邮件链接不可复刻，但 `DisplayFlag=1` 查询链接可用商户密钥生成）。

---

## D12. 发票「查看链接」能否自动生成（邮件链接 vs DisplayFlag=1）

**背景**：客户收到的邮件里有一个 `https://cinv.ezpay.com.tw/invoice_index/search_platform?PostData=<密文>` 链接，点开即展示发票。问：我们能否自己生成同类链接？

**结论**：**邮件那个链接复刻不了；但 `/Api/invoice_search` + `DisplayFlag=1` 这条我们能生成。**

| 链接来源 | 加密密钥 | 我们能生成 | 效果 |
|----------|----------|------------|------|
| 邮件 `invoice_index/search_platform` | ezPay **平台**密钥（推测） | ❌ 不能 | GET URL，点开展示发票 |
| `/Api/invoice_search` + `DisplayFlag=1` | **商户** HashKey/HashIV | ✅ 能 | Form POST 跳 ezPay 页面展示发票 |

**验证（邮件链接不可复刻）**：邮件 PostData = 96 字节 = 3×32 块（ezPay AES block-32 格式特征）。用商户密钥(`J3ln4b6axMafUE1HMTF3boeqTCoD5Ia5` / `CgabhUpEbwpoATlP`)解密 → 高熵乱码（96 字节仅 37 个可打印 ASCII，pad=42 非法），CBC/ECB/零IV 三种变体可打印率均低；AES-CBC round-trip 自检通过 → **证明商户密钥解不开，邮件链接用的是另一把（平台）密钥**。

**可生成路径（DisplayFlag=1）**：手册第八章，`invoice_search` 带 `DisplayFlag=1` 时 ezPay「以網頁顯示發票查詢結果」。明文示例：
```
RespondType=JSON&Version=1.3&TimeStamp=<ts>&SearchType=0
&InvoiceNumber=<发票号>&RandomNum=<随机码>&DisplayFlag=1
```
用 `EzPayEncryptUtil.encrypt` + 商户密钥加密 → Form POST `MerchantID_`+`PostData_` 到 `/Api/invoice_search`。

**落地形态**：是 **Form POST 不是 GET URL**，没法存成超链接直接发。方案：点「查看发票」时后端即时生成 PostData（TimeStamp 现生成、防过期）→ 返回自动提交的小页 → 跳 ezPay 展示。密钥留后端，前端只拿 URL。

**⚠️ 待实测确认**（已生成测试表单 `%TEMP%\ezpay_view_test.html`，用测试票 `DK00000003/5071` + 商户密钥）：
1. 点开是否正常展示发票（不报错）；
2. 落地页是否与邮件链接同页/相似；
3. 是否要求登 ezPay（决定能否当客户-facing 链接）。
若要求登录或失效 → 此路仅限内部/商家用，客户-facing 仍走邮箱/载具。

---

## D13. 发票打印：无 API/模板 + B2C 列印限制 + 交付路径

**来源**：官方手册 4 份 + ezPay QA 页（`cinv.ezpay.com.tw/Invoice_index/qa`）。

**结论：ezPay 不提供打印 API、无可下载模板、无发票图片/PDF 接口。「拿样票装数据打印」这条路不存在。**

**打印的实际形态（QA 第八章「列印發票作業」）**：
- **纯手动后台操作**：商家登 ezPay 后台「列印電子發票」→ 勾选 → 点列印 → **输入登入密码** → 生成 PDF。非 API。
- **B2C 列印 1 次限制（財政部規定）**：非首次列印必须加註「補印」，否则责任商家自负。
- **B2C 买方不能列印**：平台「僅供買受人查詢發票資訊，無法提供買受人列印服務」（防重复兑奖）。B2B 买方可在「電子發票查詢專區」凭发票号/日期/随机码列印證明聯。
- **「樣張」≠模板**：QA 第二章「發票證明聯樣張」是商家在测试区开票后用「熱感列印」产出的 PDF，用于**字轨申请交国税局**——是打印**输出**，非可填充的**输入模板**。数据开票时已存 ezPay，打印只渲染 ezPay 内数据。

**前端实现现状（平台后台 orderInvoice 详情页）**：详情页对已开票会渲染发票防伪码——用存的 `invoiceBarCode/QRcodeL/R`（ezPay 开立回应返回、PrintFlag=Y 才有）画出二维码+条码供**查看**；已去掉下载 PNG / 打印按钮（避免 D13 担心的自行打印/補印合规风险）。
- 因「B2C 必填载具 → PrintFlag=N」(见 D11)，**B2C 票无码值** → 详情显示「已存入载具，无纸本凭证」，不画码。
- **仅 B2B（PrintFlag=Y）**有码值 → 渲染 QR/条码展示。B2B 无法规列印次数限制，码值为 ezPay 官方防伪码，仅展示、不打印，合规。
- 结论：自渲染**展示**可接受（只读、B2B 限定）；自渲染**打印/下载**已移除。原 D13「不建议自行渲染打印」订正为此。

**发票交付的现实路径（就这三条，无「自己打印」）**：

| 场景 | 怎么把发票给到人 | 谁操作 |
|------|------------------|--------|
| B2C | 邮箱（ezPay 发邮件）/ 载具（客户去财政部·ezPay 查） | ezPay 自动 / 客户自取 |
| B2B | 商家在 ezPay 后台手动印 PDF 交买方；或买方在查询专区自印 | 商家 / 买方 |
| B2C 中奖票 | 商家中奖后在 ezPay 后台印熱感紙證明聯给买方兑奖 | 商家手动 |

**对外卖平台的落地建议**：B2C 走邮箱/载具送达（不需、也不能随便打印）；B2B 打印让商家去 ezPay 后台做（或我们做个「跳转 ezPay 后台」快捷入口，印的动作仍在 ezPay）。平台自渲染/打印发票不建议做。

---

## 附：载具（Carrier）业务概念（参考）

**载具 = 电子发票的「钱包/账户」。** 发票不印纸、不发文件，而是像存钱一样「存」进客户的某个发票账户；客户看的不是「一张票」，而是「去自己账户翻这笔记录」。

- **纸质发票**：商家印纸给你，票在手上（物理持有）。
- **载具发票**：商家不给任何东西，把发票数据上报财政部电子发票平台、打上「属于载具号 XXX」标记；票以记录形式躺在该载具名下。

ezPay `Category=B2C` 三种载具（`CarrierType`）：

| 载具 | 号码 | 票存于 |
|------|------|--------|
| `0` 手机条码 | `/ABC1234`（/开头） | 财政部电子发票平台，绑手机 |
| `1` 自然人凭证 | `2字母+14数字` | 财政部平台，绑身份证 |
| `2` ezPay会员载具 | ezPay会员号（如截图 `C1597…`） | ezPay 系统 |

- **「存进载具」**：票上报到财政部/ezPay 平台、标记归属某载具号；选载具则 `PrintFlag=N`（不打印）。
- **「客户自己去查」**：票存在财政部/ezPay、不在我们手里，我们只存了发票号、给不出票图。客户须登票据所在地查看：手机条码/自然人凭证 → 财政部电子发票整合服务平台（einvoice.nat.gov.tw）或「财政部发票夹」App；ezPay会员载具 → ezPay App/网站。
- **连带好处（中奖）**：用载具的票，开奖时财政部自动比对、奖金拨入载具绑定账户，客户无需持纸票兑奖——这也是台湾主推载具的原因。

> 一句话：载具=客户在政府/ezPay 的「发票账户」；存进载具=票打进该账户；自己去查=客户登该账户才能看到票。这是明细页「无发票链接可显示」的根本原因。

### 补充：查询发票明细接口（invoice_search）能力备忘

ezPay **有**查询发票明细的接口 `/Api/invoice_search`(v1.3)，工具类已封装 `EzPay.searchInvoice(baseUrl, cfg, invoiceNumber, randomNum)`（`ruoyi-admin/.../utils/ezPay/EzPay.java`），按 D10 决策本期业务层不调用它。其回应字段（手册第八章）覆盖：MerchantID/InvoiceTransNo/MerchantOrderNo/InvoiceNumber/RandomNum/BuyerName/BuyerUBN/BuyerAddress/BuyerPhone/BuyerEmail/InvoiceType/Category/TaxType/TaxRate/Amt(含AmtSales/Zero/Free)/TaxAmt/TotalAmt/CarrierType/CarrierNum/LoveCode/PrintFlag/KioskPrintFlag/CreateTime/ItemDetail(JSON)/InvoiceStatus/UploadStatus/CheckCode/BarCode/QRcodeL/QRcodeR。**注意**：中奖注记、目前可折让金额、通知状态、折让单号该接口**不返回**（属 ezPay 控制台聚合 / 折让接口范畴）。日后若需「确认上传状态」等 live 字段再启用此接口。
