research.md 19 KB

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=5MerchantOrderNo=订单 ddId(同商店唯一)。

Rationale: 来自 ezPay INVI 规格(记忆)。PrintFlag 规则:无载具/捐赠时必 Y;B2B 必 Y。本期不做捐赠 → LoveCode 恒空。


D5. 防重复开票(并发安全)

Decision:

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

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

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


D6. 作废与重开

Decision:

  • 作废:状态为「已开」时调 EzPayinvoice_invalidURL_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 错误前缀)→ 失败,记 Messagefail_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):当前明细页(selectInvoiceDetailPosOrderInvoiceVo)不展示逐商品行项,故无需存。日后若明细页要展示商品明细,再考虑开票时存一份(避免优惠按比例分摊后、行项与总额对不上)。


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_searchDisplayFlag=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 字段再启用此接口。