LikeShop 原生的发票功能是“用户申请 + 商家手工处理”的轻台账模式。用户在结算页或订单详情页勾选开票,填写发票类型(增值税普通发票/专用发票)、抬头、税号等信息,提交开票申请。商家在后台“发票管理”中看到申请,手工开具(上传 PDF 或填写发票代码、号码),然后回填到系统。商家后台“经营设置”中有发票开关,关闭后结算页不显示开票入口。
这条链路能“用”,但没有任何自动化:没有对接税务开票系统,没有自动触发开票,没有冲红联动,发票状态只是“已申请/已开具”的手工标签。更关键的是,售后发生后退款会走,但发票不会自动冲红——客服和财务需要在对账时人工发现“这单退过款但票还开着”,再回头补红冲。电商场景中,退款后发票未及时冲销,意味着企业要为已经退回的销售垫付增值税销项税,这是实打实的资金损失。
二开的核心策略是:不动原生“发票申请”的入口和字段,把发票域从“手工台账”改造成“事件驱动 + 服务商对接”的自动化链路。自动开票由订单状态事件触发,自动冲红由售后完成事件触发,发票状态和渠道返回的发票号、红票号持久化落库,后台发票管理从“手工填单”升级为“开票记录中心”。
原生的发票数据通常挤在订单表或一张简单的发票申请表里,无法承载“申请——开票——冲红——重开”的多状态流转。需要拆成三层。
2.1 发票申请单(用户侧的需求)
sql
CREATE TABLE `ls_invoice_apply` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`apply_no` varchar(32) NOT NULL COMMENT '申请单号',
`order_id` int UNSIGNED NOT NULL COMMENT '订单ID',
`order_sn` varchar(32) NOT NULL COMMENT '订单号',
`user_id` int UNSIGNED NOT NULL COMMENT '用户ID',
`invoice_type` tinyint NOT NULL COMMENT '发票类型:1-电子普票,2-电子专票',
`title_type` tinyint NOT NULL DEFAULT 1 COMMENT '抬头类型:1-个人,2-企业',
`title` varchar(100) NOT NULL COMMENT '发票抬头',
`tax_no` varchar(50) NOT NULL DEFAULT '' COMMENT '税号',
`email` varchar(100) NOT NULL DEFAULT '' COMMENT '接收邮箱',
`apply_amount` decimal(10,2) NOT NULL COMMENT '申请开票金 额',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-待开票,1-开票中,2-已开票,3-开票失败,4-已关闭',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_apply_no` (`apply_no`),
UNIQUE KEY `uk_order_id` (`order_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票申请单';
uk_order_id 唯一约束保证一笔订单只有一个活跃的发票申请,防止重复申请。apply_amount 是申请时的订单可开票金额快照,后续退款不直接改这个字段,而是通过冲红记录来关联。
2.2 发票记录(服务商返回的蓝票)
sql
CREATE TABLE `ls_invoice_record` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`apply_id` int UNSIGNED NOT NULL COMMENT '申请单ID',
`order_sn` varchar(32) NOT NULL COMMENT '订单号',
`provider` varchar(20) NOT NULL COMMENT '服务商:wechat/aisino/baiwang/nuonuo',
`fapiao_apply_id` varchar(64) NOT NULL DEFAULT '' COMMENT '服务商发票申请单号',
`invoice_code` varchar(20) NOT NULL DEFAULT '' COMMENT '发票代码',
`invoice_number` varchar(20) NOT NULL DEFAULT '' COMMENT '发票号码',
`invoice_amount` decimal(10,2) NOT NULL COMMENT '开票金额',
`invoice_date` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '开票日期',
`pdf_url` varchar(500) NOT NULL DEFAULT '' COMMENT '发票文件地址',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1-蓝票有效,2-已全额冲红',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_provider_apply` (`provider`, `fapiao_apply_id`),
KEY `idx_order_sn` (`order_sn`),
KEY `idx_invoice_number` (`invoice_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票记录(蓝票)';
fapiao_apply_id 是调用服务商开票接口时传入的申请单号,也是后续冲红接口的路径参数。微信支付的冲红接口路径为 /v3/new-tax-control-fapiao/fapiao-applications/{fapiao_apply_id}/reverse,这个ID必须在开票时持久化,否则无法发起冲红。
2.3 冲红记录(红票)
sql
CREATE TABLE `ls_invoice_reverse` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`invoice_record_id` int UNSIGNED NOT NULL COMMENT '原蓝票记录ID',
`order_sn` varchar(32) NOT NULL,
`after_sale_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '触发冲红的售后单ID',
`reverse_reason` varchar(200) NOT NULL DEFAULT '' COMMENT '冲红原因',
`reverse_type` tinyint NOT NULL DEFAULT 1 COMMENT '冲红类型:1-全额冲红,2-部分冲红',
`reverse_amount` decimal(10,2) NOT NULL COMMENT '冲红金额',
`red_invoice_number` varchar(20) NOT NULL DEFAULT '' COMMENT '红票号码',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-冲红中,1-冲红成功,2-冲红失败',
`provider_result` text COMMENT '服务商返回结果快照',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_invoice_record` (`invoice_record_id`),
KEY `idx_after_sale` (`after_sale_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票冲红记录';
三层分离的价值在于:申请是用户的意图,蓝票是开票的事实,冲红是售后引发的更正。三者独立记录,对账时才能区分“申请了没开”“开了没冲”“冲了没成功”三种断点。
3.1 触发时机与前置校验
自动开票的触发点是订单支付成功。监听 OrderPaySuccess 事件,在回调中做轻量的前置校验,然后推入队列异步执行。前置校验包括:发票开关是否开启、订单是否有有效的发票申请、订单是否已存在蓝票(幂等)。
php
class AutoInvoiceListener
{
public function handle($orderId)
{
if (!ConfigModel::get('invoice_auto_open', 0)) return;
$apply = InvoiceApplyModel::where('order_id', $orderId)
->where('status', 0)->find();
if (!$apply) return;
// 幂等:已有蓝票则跳过
$exists = InvoiceRecordModel::where('order_sn', $apply['order_sn'])
->where('status', 1)->count();
if ($exists) return;
Queue::push(AutoInvoiceJob::class, ['apply_id' => $apply['id']]);
}
}
自动开票不在支付回调的同步事务中执行。开票接口调用涉及外部服务商,延迟不可控,放在同步链路会拖慢支付回调的处理,进而影响订单状态推进。队列异步执行是唯一正确的选择。
3.2 价税分离与税收分类编码映射
自动开票和手工开票最大的技术差异在价税分离。订单实付金额是含税总额,但发票票面需要拆分“金额(不含税)”和“税额”,而且订单中的优惠(优惠券、积分抵扣、满减)必须按合规方式分摊到票面折扣行,不能简单地把实付金额当作开票金额。
价税分离的简化实现按订单商品的税率分组计算:
php
public function calcInvoiceAmount($order)
{
$items = OrderGoodsModel::where('order_id', $order['id'])->select();
$result = [];
foreach ($items as $item) {
$taxRate = GoodsModel::where('id', $item['goods_id'])->value('tax_rate') ?: 0.13;
$含税金额 = $item['total_price'];
$不含税 = round($含税金额 / (1 + $taxRate), 2);
$税额 = round($含税金额 - $不含税, 2);
$result[] = compact('taxRate', '不含税', '税额');
}
return $result;
}
同时,每个商品必须映射到商品和服务税收分类编码。编码错配会导致购买方无法报销,也会在金税四期的监管筛查中触发预警。二开中应在商品表上增加 tax_code 字段,商品上架时由运营或通过规则自动填充,开票时直接读取,不在开票环节让财务手工选编码。
3.3 服务商适配与结果落库
不同服务商的接口规范差异很大,用适配器隔离。微信支付电子发票的流程是:开票时传入 fapiao_apply_id,调用《开具电子发票》接口,成功后通过《查询电子发票》获取发票代码、号码和 PDF 信息。航天信息、百望等税控服务商的接口则围绕税控设备或数电平台封装。
适配器统一返回 invoice_code、invoice_number、pdf_url,由服务层写入 ls_invoice_record,并将申请单状态推进为“已开票”。开票结果必须落快照,包括服务商原始返回报文,后续冲红时 fapiao_information 中的发票代码和号码需要与原开票时完全一致。
4.1 触发时机:售后完成,不是退款发起
冲红的触发点必须是售后单状态变为“售后成功/退款完成” ,而不是退款发起时。退款发起时资金可能还在处理中,此时冲红存在“钱没退成、票已冲掉”的风险。监听售后成功事件,查询该订单是否存在有效蓝票,存在则推入冲红队列。
php
class AfterSaleRefundListener
{
public function handle($afterSaleId)
{
$afterSale = AfterSaleModel::find($afterSaleId);
if (!$afterSale || $afterSale['status'] != 2) return; // 仅售后成功
$invoice = InvoiceRecordModel::where('order_sn', $afterSale['order_sn'])
->where('status', 1)->find();
if (!$invoice) return;
Queue::push(InvoiceReverseJob::class, [
'invoice_record_id' => $invoice['id'],
'after_sale_id' => $afterSaleId,
'refund_amount' => $afterSale['refund_total_amount'],
]);
}
}
4.2 全额冲红与部分冲红的边界
微信支付的冲红接口只要求传入 reverse_reason 和原发票的 fapiao_id、fapiao_code、fapiao_number,接口本身不区分全额还是部分,但税务规则上,部分冲红需要遵循红字确认单的流程。对于电商场景,简化策略是:
- 整单退款:全额冲红,原蓝票
status置 2(已全额冲红),冲红记录reverse_type = 1。 - 部分退款:优先走部分红冲(如果服务商支持并返回红票号码);如果服务商接口不支持部分金额冲红,则退化为“全额冲红 + 按退款后金额重开蓝票”。重开蓝票必须生成新的
fapiao_apply_id,不能复用原申请单。
冲红接口返回成功不代表冲红完成。微信支付明确说明:冲红请求受理后,最终结果通过回调通知或主动查询《查询电子发票》获取。因此冲红记录的状态流转为“冲红中 → 冲红成功/失败”,以查询或回调结果为准,不以接口受理为终态。
4.3 冲红的幂等与防重复
同一张蓝票只能被有效冲红一次。在发起冲红前,用条件更新抢占冲红记录:
php
$affected = Db::name('invoice_reverse')
->where('invoice_record_id', $invoiceRecordId)
->where('status', 0)
->update(['status' => 1, 'update_time' => time()]);
if (!$affected) return; // 已被其他任务处理
同时,蓝票表的 status 从 1 到 2 的更新也使用条件更新,双重保险防止重复冲红。退款回调可能重投,售后事件也可能重复触发,幂等设计是冲红链路的安全底线。
5.1 后台发票记录中心
原生的“发票管理”是手工填单页,二开把它升级为发票记录中心,按申请单号、订单号、发票号码、开票状态、冲红状态多维度检索。每一行展示:申请时间、开票时间、发票类型、蓝票号码、冲红状态、PDF 下载。对于“开票失败”和“冲红失败”的记录,提供手动重试入口,重试同样走队列,不在后台同步调用服务商接口。
5.2 三本账的对账关系
发票对账需要三本账闭合:
订单账:已支付且可开票的订单金额。
发票账:已开具的蓝票金额 + 已冲红的红票金额。
税务账:服务商返回的发票号码和 PDF 文件对应的实际票面金额。
闭合关系为:订单可开票金额 = 有效蓝票金额 + 已冲红金额。如果订单已退款但发票仍为“蓝票有效”,对账时标记为“应冲未冲”;如果发票已冲红但退款未完成,标记为“已冲未退”。定时任务每日扫描这两类异常,写入对账差异表。
发票号必须全局唯一。同一张蓝票不能重复开具,同一张红票不能重复生成。ls_invoice_record 的 invoice_number 和 ls_invoice_reverse 的 red_invoice_number 应加唯一索引(允许空值),作为防重复开票的最后防线。
6.1 后台配置清单
| 配置项 | 类型 | 说明 |
|---|---|---|
| 发票功能开关 | 开关 | 关闭后结算页不显示开票入口,自动开票不触发 |
| 自动开票 | 开关 | 支付成功后是否自动开具 |
| 开票触发节点 | 单选 | 支付成功 / 订单完成 / 发货后 |
| 对接服务商 | 单选 | 微信支付电子发票 / 航天信息 / 百望 / 诺诺 |
| 默认税率 | 百分比 | 商品未配置税率时的兜底税率 |
| 自动冲红 | 开关 | 售后成功后是否自动发起冲红 |
| 部分退款处理 | 单选 | 部分红冲 / 全额冲红后重开 |
| 冲红原因模板 | 文本 | 默认“销售退回”,可自定义 |
| 开票失败重试次数 | 数字 | 队列重试上限,默认 3 |
6.2 开发注意事项
开票和冲红都必须异步,不能堵在支付回调或售后回调的同步事务里。服务商接口的延迟和失败率不可控,同步调用会拖垮核心交易链路。队列异步 + 状态落库 + 手动重试,是发票域唯一合理的执行模型。
fapiao_apply_id 是冲红的关键钥匙,开票时必须落库。微信支付的冲红接口以 fapiao_apply_id 为路径参数,丢失这个 ID 就无法发起冲红。开票记录中的 fapiao_apply_id 应该加唯一约束,防止并发开票产生重复的申请单。
冲红不以接口受理为终态。微信冲红接口返回 202 只代表请求被受理,最终结果需要通过查询或回调确认。冲红记录的状态机必须包含“冲红中”这个中间态,定时任务扫描超过合理时长仍为“冲红中”的记录,主动查询服务商结果。
兼容性。新增的发票申请单、发票记录、冲红记录三张表独立于 ls_order。原生“发票管理”的手工填单功能保留,作为服务商不可用时的降级方案;自动开票和自动冲红作为可选开关,关闭时完全走原生手工链路。商品表的 tax_code 字段新增时允许为空,存量商品不强制回填,开票时对空编码做兜底提示而非阻断。
