research.md 7.0 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 网络)。