# 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 网络）。
