Feature: 演进 010-order-invoice(订单 ezPay 电子发票开立)
Created: 2026-07-27
Status: Draft(待审)
Supersedes: 010 spec.md 中「客户在订单完成后手动申请开票」的触发/入口模型(改为下单捕获 + 出餐自动)。010 已建的 B2C/B2B/载具/捐赠/作废/重试/码值落库等开票核心全部复用,不在本期重做。
010 已实现「客户在订单完成后进订单详情手动申请发票」。现按全联福利中心(PX Mart)模式演进为:下单(结账)时选发票类型(默认个人纸本),商家出餐时自动开票。目标:
参考:全联福利中心 App、老板提供的纸本发票样张(标准台湾电子发票证明联:左右两组 QR 码 + 条码 + 发票号/卖方统编/买方/明细/随机码)。
本期做(后端为主):
createOrder)捕获发票意图dispatchOrder)自动开票checkBarCode)info_invoice 作「我的发票抬头」管理)info_invoice 加「是否默认」标记本期不做(留增量):
| # | 决策 |
|---|---|
| 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 未开通/未启用)→ 结账页不展示发票选项,出餐跳过自动开票 |
发票类型:
○ 个人发票 ← 默认选中
│ └─ 这张个人发票怎么处理?
│ ○ 纸本 ← 默认,零输入,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)。
createOrder)OrderCreateInput 新增发票意图字段:invoiceChoice(PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、carrierType、carrierNum、buyerUbn、buyerName(公司名,仅 COMPANY)、loveCode、saveAsDefault(boolean)。createOrder 从 JWT 取 userId → 存订单时把发票意图写入 pos_order_invoice 意图行(issue_triggered=0 未触发,见 §7;方案B,不再落 pos_order)。canInvoice(mdId)==false → 该单不写发票意图(等价不开票);客户端结账页按 canInvoice 接口隐藏发票选项。saveAsDefault=true → 将该载具/统编抬头写入 info_invoice 并标记为默认(其余取消默认)。dispatchOrder,state 1→2)PosOrderShOprateController.dispatchOrder 在 setState(2) 之后调用 OrderInvoiceService.autoIssue(orderId)。autoIssue 流程:
pos_order_invoice 发票意图行(issue_triggered=0) + 门店 ezPay 凭证(assertInvoiceable,复用 010)。ApplyInvoiceDto(意图 → category/载具/统编/捐赠);个人 BuyerName = InfoUser.phone。buildIssueData + ezPay.issueInvoice + 落库)。放宽两点:不校验客户归属(系统触发)、不强制 payStatus==1(货到付款出餐时未付款也要开,见 D9)。issue_triggered=1,状态 已开 / 失败(失败由运营在后台重试,复用 010 retry)。GET /system/userOrder/myInvoices(JWT,按 userId 分页)→ pos_order_invoice i JOIN pos_order o ON i.order_id = o.id WHERE o.user_id = ?。OrderInvoiceService.invalid(orderId)(复用 010)作废发票。取消链路挂钩点见 §11。InfoUser.phone。客户什么都不填也能开。需补 010 缺的「B2C 无载具→PrintFlag=Y」分支:现 validateInvoiceInput 强制 B2C 有载具(487–520 行),buildIssueData B2C 永远 PrintFlag=N(540–548 行)。invoice_search 重复取),但平台不提供补打 / 不重渲染码图,守住商家合规线。会员纸本丢失 → 凭发票号 + 随机码兑奖。resolveLoveOrg 回填)。amount − freight(不含运费,沿用 010 research D1)。updatesql/sql.md,不直接执行)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)。| 输入 | 校验方式 | 接入点 |
|---|---|---|
| 手機條碜 | 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 凭证。OrderInvoiceService 开票核心(buildIssueData/issue/落库)、作废(invalid)、重试(retry);PosOrderInvoice 实体 + mapper + 码值字段(Phase 9)。EzPay/EzPayConfig/EzPayEncryptUtil(checkLoveCode 已有,加 checkBarCode)。canInvoice/assertInvoiceable)。info_invoice 表 + InfoInvoiceController CRUD(复用为默认载具/抬头存储 + 管理入口)。OrderCreateInput:加发票意图字段。POST /system/userOrder/createOrder:存发票意图到 pos_order;载具/捐赠验真;saveAsDefault 写 info_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 处理)。pos_order_invoice.status=失败 可重试),实现阶段定。PosOrderShOprateController / 用户取消 UserOrderController),在该取消链路挂钩 invalid。实现阶段对齐现有取消入口。/applyInvoice 手动入口去留:自动开票后客户无需手动申请。倾向保留作为「自动开票失败时客户可手动重试」的兜底入口(或仅运营后台重试即可,移除客户手动入口)。实现阶段定。createOrder 已拆子订单,发票意图随子订单存。实现阶段核实 parent/子订单归属与意图传递。checkBarCode 验真通过 → 出餐自动开成载具发票(PrintFlag=N)。本设计审通过后,按项目「追加需求直接更新现有 spec/plan/tasks」习惯,把本设计折叠进 specs/010-order-invoice/ 的 spec.md(新增自动开票 user story / FR)、plan.md(本设计)、tasks.md(新增任务),再进入实现。