auto-invoice-design.md 12 KB

订单电子发票:全联式自动开票设计(010 演进)

Feature: 演进 010-order-invoice(订单 ezPay 电子发票开立) Created: 2026-07-27 Status: Draft(待审) Supersedes: 010 spec.md 中「客户在订单完成后手动申请开票」的触发/入口模型(改为下单捕获 + 出餐自动)。010 已建的 B2C/B2B/载具/捐赠/作废/重试/码值落库等开票核心全部复用,不在本期重做。

1. 背景与动机

010 已实现「客户在订单完成后进订单详情手动申请发票」。现按全联福利中心(PX Mart)模式演进为:下单(结账)时选发票类型(默认个人纸本),商家出餐时自动开票。目标:

  • 零信息也能开(默认个人纸本,BuyerName 取手机号),解决「客户没设载具怎么开」。
  • 发票自动随订单流转,纸本由骑手送餐时一并交付
  • 会员有「我的发票」夹,App 内随时查看。
  • 开发票门店每笔必开;免用发票门店不开。

参考:全联福利中心 App、老板提供的纸本发票样张(标准台湾电子发票证明联:左右两组 QR 码 + 条码 + 发票号/卖方统编/买方/明细/随机码)。

2. 范围

本期做(后端为主):

  • 下单(createOrder)捕获发票意图
  • 商家出餐(dispatchOrder)自动开票
  • 个人纸本零信息兜底分支(补 010 缺的 B2C 无载具→PrintFlag=Y 分支)
  • 载具校验(接入 checkBarCode)
  • 会员发票夹列表接口(按 user_id)
  • 发票信息双入口(结账时设 + 复用 014 info_invoice 作「我的发票抬头」管理)
  • info_invoice 加「是否默认」标记

本期不做(留增量):

  • 客户端 App 的结账页发票选择 UI、我的发票夹 UI、我的发票抬头管理 UI(沿用 010 分工,客户端团队对接)
  • POS 实体打印机对接(纸本证明联由商家自行打印,平台只生成码值)
  • 发票折让、字轨管理、批次开立、中奖通知

3. 核心决策(已与需求方确认)

# 决策
D1 触发时机 = 商家出餐 dispatchOrder(state 1→2)统一开所有档位
D2 全联式 UX:发票类型在下单(结账)时选,顶层只有 个人发票 / 公司发票 两种
D3 个人发票默认 = 纸本(零输入);要载具才现填手機條碜/自然人凭证;捐赠归在个人发票下(非并列类型)
D4 个人(B2C)BuyerName = 用户手机号(InfoUser.phone,自动取,客户不填)
D5 「同意设置为默认」复选框 + 「选用常用载具/抬头」:复用 014 info_invoice 存储,加「是否默认」标记;只对载具与统编抬头生效,捐赠不存
D6 发票信息双入口:① 结账时设 ②「我的发票抬头」管理页(014 后端已就绪)
D7 纸本(PrintFlag=Y)证明联只打一次,骑手送餐带上;发票夹只展示结构化信息,不补打、不重渲染码图
D8 载具/捐赠(PrintFlag=N)无纸本,发票夹标注「已存入载具/已捐赠」
D9 货到付款:出餐照开(纸本要赶骑手),送达失败/取消 → 作废(复用 010 invalid)
D10 门店不可开票(canInvoice=false:免用发票或 ezPay 未开通/未启用)→ 结账页不展示发票选项,出餐跳过自动开票

4. 结账页发票信息架构(客户端 UI 参考)

发票类型:
  ○ 个人发票                      ← 默认选中
  │    └─ 这张个人发票怎么处理?
  │        ○ 纸本                  ← 默认,零输入,BuyerName=手机号,PrintFlag=Y
  │        ○ 存手機條碜载具          [填条码 / 选用常用] [☐ 同意设置为默认]
  │        ○ 存自然人凭证载具        [填凭证]
  │        ○ 捐赠给社福机构          [选机构 / 填捐赠码],PrintFlag=N
  │
  ○ 公司发票
       └─ 统一编号 [填] + 公司名称 [填],PrintFlag=Y (B2B)

ezPay 映射:

结账选择 Category 关键字段 PrintFlag
个人→纸本(默认) B2C BuyerName=手机号,无载具无捐赠 Y
个人→手機條碜 B2C CarrierType=0 + CarrierNum N
个人→自然人凭证 B2C CarrierType=1 + CarrierNum N
个人→捐赠 B2C LoveCode N
公司发票 B2B BuyerUBN + BuyerName(公司名) + BuyerEmail Y

010 现有 ApplyInvoiceDto.DONATION 独立类型可废弃:捐赠统一为 B2C + loveCode(代码 buildIssueData 已有此分支,见 010 T041)。

5. 架构与数据流

5.1 下单捕获(createOrder)

  • OrderCreateInput 新增发票意图字段:invoiceChoice(PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、carrierTypecarrierNumbuyerUbnbuyerName(公司名,仅 COMPANY)、loveCodesaveAsDefault(boolean)。
  • createOrder 从 JWT 取 userId → 存订单时把发票意图写入 pos_order_invoice 意图行(issue_triggered=0 未触发,见 §7;方案B,不再落 pos_order)。
  • 门店守卫:canInvoice(mdId)==false → 该单不写发票意图(等价不开票);客户端结账页按 canInvoice 接口隐藏发票选项。
  • 载具/捐赠验真(见 §8):不通过则拦截下单。
  • saveAsDefault=true → 将该载具/统编抬头写入 info_invoice 并标记为默认(其余取消默认)。

5.2 商家出餐自动开票(dispatchOrder,state 1→2)

  • PosOrderShOprateController.dispatchOrdersetState(2) 之后调用 OrderInvoiceService.autoIssue(orderId)
  • autoIssue 流程:
    1. pos_order_invoice 发票意图行(issue_triggered=0) + 门店 ezPay 凭证(assertInvoiceable,复用 010)。
    2. 门店不可开票 / 订单无发票意图 → 跳过(不报错,不影响出餐)。
    3. 组装 ApplyInvoiceDto(意图 → category/载具/统编/捐赠);个人 BuyerName = InfoUser.phone
    4. 复用 010 开票核心(buildIssueData + ezPay.issueInvoice + 落库)。放宽两点:不校验客户归属(系统触发)、不强制 payStatus==1(货到付款出餐时未付款也要开,见 D9)。
    5. PrintFlag=Y → 码值(BarCode/QRcodeL/R)落库,供商家打印证明联。
  • 在意图行上落结果(复用 010)并置 issue_triggered=1,状态 已开 / 失败(失败由运营在后台重试,复用 010 retry)。

5.3 会员发票夹(我的发票)

  • 新增 GET /system/userOrder/myInvoices(JWT,按 userId 分页)→ pos_order_invoice i JOIN pos_order o ON i.order_id = o.id WHERE o.user_id = ?
  • 返回字段裁剪:PrintFlag=Y → 结构化信息(发票号/随机码/金额/明细/门店)+「纸本已开」,不返回码图、无补打;PrintFlag=N → 结构化信息 +「已存入载具/已捐赠」。
  • 客户端 UI 由客户端团队对接。

5.4 货到付款兜底(D9)

  • 出餐照开(含货到付款)。若送达失败 / 订单取消(state→4)→ 调 OrderInvoiceService.invalid(orderId)(复用 010)作废发票。取消链路挂钩点见 §11。

6. 关键业务规则

  • 零信息兜底:个人纸本 = B2C + 无载具 + 无捐赠 → PrintFlag=Y(法定强制),BuyerName=InfoUser.phone。客户什么都不填也能开。需补 010 缺的「B2C 无载具→PrintFlag=Y」分支:现 validateInvoiceInput 强制 B2C 有载具(487–520 行),buildIssueData B2C 永远 PrintFlag=N(540–548 行)。
  • 证明联一次为限:财政部规定证明联以输出一次为限。ezPay 技术上不锁(码值可经 invoice_search 重复取),但平台不提供补打 / 不重渲染码图,守住商家合规线。会员纸本丢失 → 凭发票号 + 随机码兑奖。
  • PrintFlag 互斥:载具/捐赠 PrintFlag=N,纸本/公司 PrintFlag=Y;载具不能再印纸本(防一票两份,ezPay 校验拦截)。
  • BuyerName:个人 = 手机号;公司 = 公司名(客户填);捐赠 = 机构名(开票时 resolveLoveOrg 回填)。
  • 金额:发票金额 = amount − freight(不含运费,沿用 010 research D1)。

7. 数据模型变更(SQL 写 updatesql/sql.md,不直接执行)

  • 方案B:发票意图(下单捕获)落 pos_order_invoice 意图行(与开票结果同表,避免意图/结果双写);pos_order 不加 发票列。
  • pos_order_invoice 新增:invoice_choice VARCHAR(16)(下单意图 PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、issue_triggered TINYINT NOT NULL DEFAULT 0(是否已触发开票,与 invoice_status 独立:下单捕获=0、出餐开票执行过=1;管理端发票管理按 issue_triggered=1 筛选,排除未触发的意图行)。buyer_ubn/carrier_type/carrier_num/love_code 复用已有列。
  • info_invoice 新增 is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头'(014 原「无默认」FR-006,现加默认标记支持 D5)。

8. 载具校验

输入 校验方式 接入点
手機條碜 ezPay checkBarCode(IsExist Y/N) 结账填入 / 存默认时
捐赠码 ezPay checkLoveCode(010 已实现) 结账填入(从开票前置到下单)
自然人凭证 仅格式正则(^[A-Z]{2}\d{14}$),无 API 结账填入
公司统编 格式正则(^\d{8}$) 结账填入
  • 新增 EzPay.checkBarCode(baseUrl, cfg, barCode) 方法(平行 checkLoveCode;EzPayEncryptUtil 的 CheckValue/CheckCode 已就绪;URL_CHECK_BARCODE 常量已定义)。
  • checkBarCode 按门店凭证鉴权 → 结账验真用该订单门店的 ezPay 凭证。

9. 复用 010 / 009 / 014 的部分(不改)

  • 010:OrderInvoiceService 开票核心(buildIssueData/issue/落库)、作废(invalid)、重试(retry);PosOrderInvoice 实体 + mapper + 码值字段(Phase 9)。
  • 工具类:EzPay/EzPayConfig/EzPayEncryptUtil(checkLoveCode 已有,加 checkBarCode)。
  • 009:门店 ezPay 凭证 + 免用发票标识(canInvoice/assertInvoiceable)。
  • 014:info_invoice 表 + InfoInvoiceController CRUD(复用为默认载具/抬头存储 + 管理入口)。

10. 接口变更概览

  • OrderCreateInput:加发票意图字段。
  • POST /system/userOrder/createOrder:存发票意图到 pos_order;载具/捐赠验真;saveAsDefaultinfo_invoice
  • PosOrderShOprateController.dispatchOrder:出餐 setState(2) 后调 autoIssue
  • OrderInvoiceService.autoIssue(orderId):新增(复用开票核心,放宽归属/payStatus 校验)。
  • OrderInvoiceService.validateInvoiceInput / buildIssueData:放宽 B2C(允许无载具)、补「无载具→PrintFlag=Y 纸本」分支、个人 BuyerName 取 InfoUser.phone
  • EzPay.checkBarCode:新增。
  • GET /system/userOrder/myInvoices:新增(发票夹列表)。
  • info_invoice 默认标记:InfoInvoiceController 加设默认 / 取默认能力(或前端按 is_default 处理)。
  • i18n:结账页发票选项文案属客户端 UI,由客户端团队;后端无新增面向用户文字(沿用 010 FR-012 分工)。

11. 开放问题 / 待确认

  • autoIssue 同步 vs 异步:出餐接口同步调 ezPay 开票会增加出餐耗时(ezPay 响应通常 <2s,可接受);若担心阻塞出餐,改异步(线程池 / MQ)+ 失败由运营后台重试。倾向同步(简单,失败有 pos_order_invoice.status=失败 可重试),实现阶段定。
  • 货到付款作废触发点:订单取消(state→4)的入口需确认(商家取消 PosOrderShOprateController / 用户取消 UserOrderController),在该取消链路挂钩 invalid。实现阶段对齐现有取消入口。
  • /applyInvoice 手动入口去留:自动开票后客户无需手动申请。倾向保留作为「自动开票失败时客户可手动重试」的兜底入口(或仅运营后台重试即可,移除客户手动入口)。实现阶段定。
  • 多店合并下单(OrderParent):一次下多店,发票按子订单(pos_order,每店一张)各自开。createOrder 已拆子订单,发票意图随子订单存。实现阶段核实 parent/子订单归属与意图传递。

12. 成功标准

  • 客户结账选「个人发票→纸本」(零输入)下单 → 商家出餐 → 自动开成个人纸本,码值落库,商家可打印证明联。
  • 客户选「手機條碜」填码 → checkBarCode 验真通过 → 出餐自动开成载具发票(PrintFlag=N)。
  • 门店免用发票 / ezPay 未开通 → 结账无发票选项,出餐不开票。
  • 货到付款订单出餐照开;送达失败取消 → 发票作废。
  • 会员在「我的发票」看到本平台开给他的所有发票(纸本看结构化信息、载具看「已存入载具」)。
  • 开发票门店每笔订单 100% 自动开出(不依赖客户手动申请)。

13. 后续

本设计审通过后,按项目「追加需求直接更新现有 spec/plan/tasks」习惯,把本设计折叠进 specs/010-order-invoice/spec.md(新增自动开票 user story / FR)、plan.md(本设计)、tasks.md(新增任务),再进入实现。