# Feature Specification: 订单 ezPay 电子发票开立

**Feature Branch**: 不新建分支（在当前 test 分支开发）

**Created**: 2026-06-16

**Status**: Draft

**Input**: User description: "订单完成后客户要开发票（ezPay 电子发票开票）。基于已完成的 009-ezpay-invoice-onboarding（门店 ezPay 开通管理 + 凭证录入），复用已有 EzPay 开票工具类，在订单完成时为已开通且启用的门店开立电子发票。不创建分支，在当前 test 分支开发。"

## User Scenarios & Testing *(mandatory)*

### User Story 1 - 客户在订单完成后申请个人电子发票 (Priority: P1)

客户下单并在订单完成后，可在订单详情发起开票，选择开立个人（B2C）电子发票并填写接收邮箱；系统通过门店已配置的 ezPay 凭证即时开立电子发票，返回发票号码与发票查看凭证，客户可查看/下载。仅「已开通且启用」的门店能为客户开票；免用发票门店不提供开票入口。

**Why this priority**: 这是「客户能开到发票」的核心价值，也是 ezPay 开通管理（009）真正落地的出口。没有它，门店开通 ezPay 没有任何意义。

**Independent Test**: 对一笔已完成订单（所属门店已开通且启用 ezPay）申请个人发票 → 系统开立成功 → 订单显示发票号、客户可查看发票。

**Acceptance Scenarios**:

1. **Given** 订单已完成且所属门店 ezPay 已开通且启用，**When** 客户在订单详情点「申请发票」、选个人发票并填接收邮箱，**Then** 系统调 ezPay 即时开立，订单记录发票号与防伪随机码，客户拿到发票查看凭证。
2. **Given** 订单所属门店为「免用发票」或 ezPay 未开通/未启用，**When** 客户查看订单，**Then** 不显示开票入口（或提示该门店暂不支持开票）。
3. **Given** 订单已成功开票，**When** 客户再次查看订单，**Then** 显示发票号与发票链接，且无法再次申请开票。

---

### User Story 2 - 客户申请公司（B2B）电子发票并填写统一编号 (Priority: P1)

客户可选择开立公司（B2B）发票，填写公司名称与统一编号（统编，8 码）；系统校验统编格式后调 ezPay 开立三联式发票，发票上印有买方统编。

**Why this priority**: 外卖/餐饮场景下 B2B 报账是刚需（统编为标配），与个人开票同为核心开票路径。

**Independent Test**: 对一笔已完成订单选公司发票、填公司名 + 合法统编 → 开立成功 → 发票记录买方统编。

**Acceptance Scenarios**:

1. **Given** 订单已完成且门店可开票，**When** 客户选公司发票并填合法统编（8 码）与公司名，**Then** 开立成功，发票记录买方统编。
2. **Given** 客户填了格式非法的统编，**When** 提交申请，**Then** 校验失败并提示，不调用开票接口。

---

### User Story 3 - 客户选择电子发票载具 (Priority: P2)

客户开立个人发票时，可选择将发票存入载具（手机条码 / 自然人凭证 / ezPay 会员载具）；不选则默认以邮箱接收。

**Why this priority**: 台湾电子发票的载具是常态体验，但并非「开到发票」的必要前提，属体验增强，故 P2。

**Independent Test**: 选手机条码载具开票 → 发票存入该载具，不另行印发邮箱 PDF。

**Acceptance Scenarios**:

1. **Given** 客户开个人发票，**When** 选手机条码载具并填条码，**Then** 校验条码合法后开立，发票存入该载具。
2. **Given** 客户开个人发票，**When** 选自然人凭证载具并填凭证，**Then** 校验后开立，发票存入该载具。

---

### User Story 4 - 商家与平台查看订单发票、失败重试 (Priority: P2)

商家在商家端、运营在平台后台可查看订单的开票状态（未开 / 已开 / 失败 / 作废）与发票号；开票失败的订单可由运营重新触发开票。

**Why this priority**: 运营与商家需要掌握开票情况、处理失败单，但依赖开票主链路先打通，故 P2。

**Independent Test**: 一笔开票失败的订单，运营在后台点「重新开票」 → 成功后状态变已开。

**Acceptance Scenarios**:

1. **Given** 订单曾开票失败，**When** 运营/商家查看，**Then** 显示失败状态与失败原因。
2. **Given** 一笔开票失败的订单，**When** 运营点重新开票，**Then** 系统再次调 ezPay 开立，成功则更新发票号与状态。

---

### User Story 5 - 商家作废已开立的发票 (Priority: P3)

商家/运营可对已开立的发票发起作废（受台湾法规「奇数月 14 日前可作废前两月发票」限制），作废成功后订单可重新开票。

**Why this priority**: 作废属售后补救，频率低且有严格时点限制，属辅助功能，故 P3。

**Independent Test**: 对一张本月开具的发票点作废 → 调 ezPay 作废成功 → 发票状态变作废、订单可重开。

**Acceptance Scenarios**:

1. **Given** 一张开立且在可作废期限内的发票，**When** 商家发起作废，**Then** 系统调 ezPay 作废，发票标记作废。
2. **Given** 已超过可作废期限的发票，**When** 商家发起作废，**Then** 提示不可作废并说明原因（以 ezPay 作废接口返回为准）。

---

### User Story 6 - 客户捐赠发票给社福机构 (Priority: P2)

客户开票时可选第三种类型「捐赠发票」：从平台提供的社福机构清单中选择机构（或手输捐赠码），系统校验捐赠码后通过 ezPay 以 LoveCode 开立捐赠发票（PrintFlag=N），发票捐给所选机构。客户无需填姓名/邮箱/载具/统编。

**Why this priority**: 捐赠属公益场景、非外卖开票主路径，复用已建开票链路做增量即可，故 P2。

**Independent Test**: 已完成订单选「捐赠发票」→ 从机构清单选「创世基金会」→ 开立成功 → 发票记录捐赠码 919 与机构名、无纸本凭证。

**Acceptance Scenarios:**

1. **Given** 订单已完成且门店可开票，**When** 客户选「捐赠发票」并从机构清单选择一家机构，**Then** 系统以该机构捐赠码调 ezPay 开立（LoveCode 填码、CarrierType 空、PrintFlag=N），发票记录捐赠码与机构名，客户看到「已捐赠给 XXX」。
2. **Given** 客户选「捐赠发票」并手输捐赠码，**When** 码格式非法或不存在，**Then** 提交前拦截并提示，不调 ezPay。
3. **Given** 捐赠发票开立成功，**When** 客户/商家/运营查看，**Then** 显示捐赠码、捐赠机构名与「已捐赠」标识，且无发票条码/二维码（PrintFlag=N 合规不索取纸本）。

---

### Edge Cases

- **同一订单重复申请开票**：已成功开票的订单不允许再次开票（除非先作废）；需防止并发点击产生重复发票。
- **ezPay 开票接口网络异常**：不开立、订单标记失败、不产生半成品发票；提示稍后重试。
- **ezPay 回应业务错误**（金额不平、字轨用尽等）：记录错误码与信息，订单标记失败、可重试。
- **客户邮箱/载具填错导致发票无法送达**：发票在 ezPay 侧已开立成功，记录发票号，提示客户联系商家；本期不做载体自动更正。
- **订单退款/取消后已开发票**：本期不自动作废，由商家按需手动作废（US5）。
- **夜市（userType=3）门店与免用发票门店**：不提供开票入口（与 009 一致）。
- **门店 ezPay 被停用（009 toggleEnable 关）**：开票时识别为不可开票，提示门店发票功能暂停。
- **捐赠码超过 ezPay 接受长度**：财政部登记允许 3-8 位，但 ezPay `invoice_issue`/`checkLoveCode` 限 3-7 位（Int(7)）；导入清单时对 >7 位的码标记不可用，客户手输 8 位码会被 ezPay 以 CLC10002 拒，提示「该捐赠码暂不支持」。
- **客户所选机构不在本地清单**：手输码走 ezPay `checkLoveCode` 验真（只回 IsExist Y/N，不回机构名）；机构名未知时显示「捐赠码有效（机构未知）」。
- **捐赠发票无纸本凭证**：PrintFlag=N 时 ezPay 不返回 BarCode/QRcode；不能复用个人发票的条码展示模板，捐赠发票详情需单独模板（结构化信息 + 「已捐赠给 XXX」）。
- **捐赠与载具互斥**：选捐赠时载具/邮箱/统编字段隐藏；若请求同时带 CarrierType 与 LoveCode，ezPay 校验拦截。

## Requirements *(mandatory)*

### Functional Requirements

- **FR-001**: 系统必须能在订单完成后，允许客户为该订单申请电子发票（仅当订单所属门店 ezPay 已开通且启用）。
- **FR-002**: 系统必须支持客户开立个人（B2C）电子发票，含接收邮箱，并通过 ezPay 即时开立接口完成开立。
- **FR-003**: 系统必须支持客户开立公司（B2B）电子发票，含买方名称与统一编号（统编，8 码），并在提交前校验统编格式。
- **FR-004**: 系统必须在开票成功后记录发票号码、防伪随机码等 ezPay 回应信息并关联到订单，供客户与商家查看。
- **FR-005**: 系统必须避免同一订单重复开票——已成功开票的订单不允许再次开票，除非该发票已作废。
- **FR-006**: 系统必须在门店为「免用发票」或 ezPay 未开通/未启用时，不向客户提供开票入口。
- **FR-007**: 开票失败（网络异常或 ezPay 业务错误）时，系统必须标记订单开票失败并记录原因，不改变订单本身状态，并允许后续重新开票。
- **FR-008**: 系统必须支持客户选择电子发票载具（手机条码 / 自然人凭证 / ezPay 会员载具）；不选则以邮箱接收。
- **FR-009**: 商家端与平台后台必须能查看订单开票状态（未开 / 已开 / 失败 / 作废）与发票号；运营可对失败订单重新开票。
- **FR-010**: 商家/运营必须能对已开立且在可作废期限内的发票发起作废，作废成功后订单可重新开票。
- **FR-011**: 系统必须复用已实现的 ezPay 开票工具类（开立 / 查询 / 作废），不在本期改动其内部实现。
- **FR-012**: 商家端、平台后台新增的面向用户文字必须实现四语言（vi / zh / tw / en）国际化；客户端 UI 的 i18n 由客户端团队另行实现，不在本期。
- **FR-013**: 系统必须存储财政部下发的捐赠机构清单（捐赠码、机构全名、机构简称、统编、县市），并支持平台同步更新（CSV 导入）。
- **FR-014**: 系统必须提供客户端可调用的接口，把捐赠机构列表返回给前端，供客户选择机构并自动回填捐赠码，支持按机构名/简称搜索。
- **FR-015**: 客户开票时可选「捐赠发票」，填入捐赠码（3-7 位数字），系统校验格式与存在性（本地清单或 ezPay `checkLoveCode`）后，调 ezPay 以 LoveCode 开立（Category=B2C、CarrierType 空、PrintFlag=N），成功后记录捐赠码与机构名。
- **FR-016**: 捐赠发票不展示纸本凭证（码图为空），客户端/管理端展示「已捐赠给 {机构}」；载具与捐赠互斥（同一张发票不可既存载具又捐赠）。

### Key Entities *(include if feature involves data)*

- **订单发票（新增，与订单 1:1）**：记录一笔订单的电子发票信息：发票类型（B2C/B2B）、买方名称、买方统编、接收邮箱、载具类型与号码、发票号码、防伪随机码、发票查看凭证、开票状态（未开 / 已开 / 失败 / 作废）、失败原因、申请时间、开立时间、作废时间。
- **订单（已有）**：开票的来源；订单完成后可发起开票；一笔订单对应一张有效发票（作废后可重开）。
- **门店 ezPay 配置（009 已有）**：开票时读取门店的 ezPay 凭证（商店代号 / HashKey / HashIV），并判断门店是否可开票（已开通且启用）。
- **ezPay 加值服务平台（外部）**：实际开立 / 作废发票的外部系统，本期通过其开立 / 作废接口与之交互。
- **捐赠机构字典表（新增，US6）**：财政部社福机构清单。字段：捐赠码（VARCHAR 保留前导零）、机构全名、机构简称（可空）、统编、县市（可空）、是否启用、同步时间。供客户端选择器与开票时回显机构名。
- **订单发票（扩展，US6）**：新增 `love_code`（捐赠码，开捐赠发票时填）与 `love_org_name`（捐赠机构名，开票时从字典快照，便于历史展示）。

## Success Criteria *(mandatory)*

### Measurable Outcomes

- **SC-001**: 客户对一笔已完成订单，能在 1 分钟内、3 次操作内完成发票申请并拿到发票查看凭证。
- **SC-002**: 门店为「免用发票」或 ezPay 未开通/未启用时，客户 100% 看不到开票入口（无法发起开票）。
- **SC-003**: 客户填错统编格式时，系统 100% 在调用开票接口前拦截并提示。
- **SC-004**: 同一订单不会因重复点击或并发申请产生 2 张及以上发票。
- **SC-005**: 开票失败（网络或业务错误）的订单，客户/运营可在不改动订单状态的前提下重新开票成功。
- **SC-006**: 客户、商家、运营三方都能在各自入口查看到同一订单的发票号与开票状态，且一致。
- **SC-007**: 客户在捐赠发票流程中能从机构列表选择机构并在 3 次操作内完成开票，发票正确捐给所选机构。
- **SC-008**: 机构列表接口返回完整清单（含搜索），客户端可直接渲染选择器并自动回填捐赠码。

## Assumptions

- 「订单完成」由现有订单状态机判定（订单到达可开票的终态），本期不改订单状态机；具体终态口径在实现阶段对齐现有逻辑。
- 开票凭证（商店代号 / HashKey / HashIV）由 009 的门店 ezPay 配置提供；仅「已开通（status=2）且启用（enabled=1）」的门店可开票。
- 复用已实现的 ezPay 工具类（EzPay / EzPayConfig / EzPayEncryptUtil），本期不改其内部。
- 餐饮主走 B2C 即时开立（Status=1）；B2B 需统编；载具为 P2 体验增强。
- 发票作废受台湾法规时点限制，系统以 ezPay 作废接口返回为准判定是否可作废，不在本地做时限计算。
- 客户端前端 UI（订单详情开票入口、发票查看）不在本期实现范围：本期交付后端开票/查询 API（供客户端调用）+ 管理端（商家/平台后台）查看/重试/作废；客户端 UI 及其 i18n 由客户端团队后续对接。
- 本期不含：发票折让、字轨管理、批次开立、发票中奖通知——这些属后续独立功能。
- 捐赠发票（LoveCode）作为 US6 增量纳入本期：客户可选「捐赠发票」类型，发票捐给社福机构。捐赠机构清单来自财政部电子发票整合服务平台（CSV 已下载于 `specs/010-order-invoice/xca.csv`，约 2016 家），需定期同步；ezPay 不提供机构列表、`checkLoveCode` 只回 IsExist Y/N 不回机构名（详见 memory `reference-lovecode-donation`）。
- 捐赠码长度以 ezPay 的 3-7 位为准（非财政部登记的 3-8 位）：导入清单时过滤 >7 位的码或标记不可用。
- 捐赠机构 CSV 数据特征：UTF-8 编码；记录以 CR（`\r`）分隔非 CRLF，导入需先归一化换行；捐赠码含前导零（如 `0329`/`0095`/`000`），须 VARCHAR 存储不可用整型；机构简称与县市可空。
- 捐赠发票 PrintFlag=N：ezPay 不返回 BarCode/QRcode 码图，遵循 `reference-tw-einvoice-concepts` 的码图规则（仅 PrintFlag=Y 才有码图）。
- 发票金额不含运费：运费由用户承担（付给配送方），非商家商品收入；商家发票仅就商品销售开立。订单构成 `amount = 商品 − 各项优惠 + 运费`，故发票金额 = `amount − 运费`，各类优惠已含在 amount 内无需逐项区分。
- 四语言 i18n 覆盖所有新增面向用户文字，遵循项目既有 i18n 规范。

---

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

> 完整设计见 [`auto-invoice-design.md`](./auto-invoice-design.md)。010 已建的 B2C/B2B/载具/捐赠/作废/重试/码值落库**全部复用**,仅改触发(手动→出餐自动)、入口(完成后申请→下单捕获),并加零信息纸本兜底 + 会员发票夹。

### User Story 7 - 全联式下单选发票 + 出餐自动开 (Priority: P1)

客户下单(结账)时选发票类型,顶层只有「个人发票 / 公司发票」两种,个人发票默认选中、默认子项=纸本(零输入)。商家出餐时系统按所选类型自动开票;纸本证明联商家打印、骑手送餐时一并交付。零信息客户(没设载具)也能开——默认个人纸本,BuyerName 取手机号。

**Independent Test**: 客户结账选「个人发票→纸本」(不填任何信息)下单 → 商家点出餐 → 系统自动开出个人纸本发票,码值落库,商家可打印证明联。

### User Story 8 - 会员发票夹 (Priority: P1)

会员在「我的发票」看到本平台开给他的所有发票:纸本发票看结构化信息(发票号/随机码/金额/明细)+「纸本已开」,载具/捐赠发票看「已存入载具/已捐赠」。平台不提供纸本补打/重渲染码图(证明联一次为限)。

### 新增功能需求(FR)

- **FR-017**: 下单(`createOrder`)时捕获发票意图(类型 + 载具/统编/捐赠码 + saveAsDefault),存 `pos_order_invoice` 意图行(`issue_triggered=0` 未触发;方案B,不再落 pos_order)。
- **FR-018**: 商家出餐(`dispatchOrder`,state 1→2)时对开发票门店自动开票;免用发票/未开通门店跳过。
- **FR-019**: 个人纸本零信息兜底——B2C 无载具无捐赠 → PrintFlag=Y,BuyerName=`InfoUser.phone`,客户不填任何信息也能开。
- **FR-020**: 载具/捐赠验真前置到下单:手機條碜调 `checkBarCode`、捐赠码调 `checkLoveCode`,验真不通过拦截下单;自然人凭证仅格式校验(无 API)。
- **FR-021**: 货到付款出餐照开;订单取消(state→4)→ 作废发票(复用 invalid)。
- **FR-022**: 会员发票夹——按 user_id 列出发票;纸本只展示结构化信息,不返回码图、无补打。
- **FR-023**: 发票信息双入口——结账时设 + 「我的发票抬头」管理(复用 014 `info_invoice`);`info_invoice` 加「是否默认」标记;默认/常用只对载具与统编抬头生效,捐赠不存。

## 演进:会员载具入结账 (2026-07-30)

> 业务把会员载具(载具类型 2)做上结账选项。载具号=会员手机号(结账零输入,出餐时取 `InfoUser.phone`),**不收 BuyerEmail**(业务方确认 ezPay 接受手机号作 type=2 载具值;BuyerEmail 仅用于 ezPay 发开立通知邮件,会员载具不要通知故不收)。会员载具属一般性/营业人载具,PrintFlag=N、不返回码值,客户去财政部全民稽核查(法规与载具概念见记忆 tw-einvoice-concepts)。

### 增量改动(后端,已实现 2026-07-30)
- `invoiceChoice` 枚举新增 `MEMBER`(原 PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)。
- 结账校验 `validateCheckoutInvoiceIntent`:MEMBER 免联网验真、免格式校验(载具号非客户输入)。
- 捕获 `createOrder`:MEMBER → `carrierType=2` 落意图行。
- 出餐自动开 `buildAutoIssueDto` + `autoIssue`:MEMBER → category=B2C、carrierType=2、载具号=会员手机号(`InfoUser.phone`)、BuyerName=手机号;同步落库载具信息。
- `validateInvoiceInput`:去掉 type=2 必填邮箱(applyInvoice 补开链路同步对齐)。
- `saveDefaultInvoiceProfile`:MEMBER 不存抬头(=手机号,无需记忆)。
- **不需 SQL**(invoiceChoice/carrierType/carrierNum 字段已存在)。

### 待办
- 客户点餐 app(独立仓库,非 foodie_server)结账发票选择器加「会员载具」选项 + 4 份 i18n(zh/tw/en/vi);仓库路径待确认。
