# 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