LikeShop 电子发票管理二开:订单自动开票、发票冲红与开票记录对接

一、原生发票的真实形态:一张“手工台账”,不是一条链路

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 字段新增时允许为空,存量商品不强制回填,开票时对空编码做兜底提示而非阻断。

0
0
0
0
评论
未登录
暂无评论