泵码、订单、发票要对得上:多支付通道之后谁负责逐笔核销|洹拓科技

一、先把“一笔”定下来,三条标识线只有一条能做主键
一项面向零售加油站的经营安排被公开宣布在全国推广,名字叫“交易即开票”,核心安排是把加油交易与发票开具衔接起来,一笔交易完成就触发出票。公告点名的支付形态包括第三方支付平台、互联网平台支付,以及加油卡与现金等多种,同一个站点里几种并存是常态。
把这句话翻译成系统要求,只有一句:每一笔交易都要在有限时间内找到一个且仅一个对应的开票结果,缺则报缺,多则报重。听起来平淡,卡住实现的第一步是“一笔”究竟指什么。
加油场景里至少有三条彼此独立的标识线。计量侧从泵码出发,配上枪号、升数与单价,它回答的是哪一把枪在哪段时间出了多少油;交易侧的订单号回答这一笔生意是谁下的、何时成交;支付侧的外部流水号回答钱从哪个通道、在哪一次交互里收到。三条线由三套系统生成,各自有各自的重发规则与失败语义,谁也不能天然代表谁。
因此必须先指定一条主键,其余两条降为映射。主键选交易侧订单号,理由是它是唯一同时具备业务语义与完整生命周期的一段,计量与支付都能往它身上挂靠。三键映射表的最小字段是:订单主键、计量侧标识(站点、枪号、泵码起止值、加油起止时间)、支付侧标识(通道类型、外部流水号、支付完成回执时间)、映射建立时间与建立方式、当前状态、末次核验结论。
两类异常要在建模时就显式承认,而不是事后清洗。一泵多单,是同一段计量被拆成多张订单;一单多支付,是一张订单由多个通道分摊,或者支付重试后留下了两条外部流水。这两类一旦不进映射表,逐笔对应就只能靠数据修复来还原,而事后还原的结论不能当作核销依据。基数不等的判定要写成显式断言:主键一侧基数为一时,另一侧基数大于一就必须落在允许的异常类型内,否则进异常台账,不放行。
二、出票要可重放,幂等键只能是订单主键
交易完成事件与开票动作之间不能是一次直接调用,中间必须留一个可重放的态。队列状态至少四档:待开、已开、开票失败、已冲正。事件到来先落队列,再由队列驱动出票,失败就重放,成功就回写票号。这样任何一次链路中断都能从队列里把该笔找回来,而不是让它悬在两个系统之间。
幂等键的选择是这段设计里最容易做错的地方,它必须是订单主键,而不是“请求一次发票”这个动作。原因很直接:以动作为键,每一次重试都是一个新键,各自都可能生成一张票;以订单主键为键,同一笔交易的任何重复请求都被折叠回同一个键,返回同一张票。两种结果的判据差异要能测出来:返回同一张票,表现为两次请求得到同一个票号、映射表里该主键只有一条已开记录;返回两张票,表现为两个票号,而映射表只记得住最后一个,前一张成了无主票据。后者比前者危险得多,前者只影响体验,后者会在对账里被读成多票。
回写这一步要单独盯住。外部出票成功的回执到达时,如果本地事务已经超时或者进程刚好重启,票就存在于服务侧而不存在于映射表里。这类外部已完成、本地未回写的状态要有专门的比对与补录任务,取数按主键而不是按时间,否则错期的那批永远扫不到。
三、四条通道各有各的结算窗口,归集不等于对齐
四类支付形态的流水与结算节奏互不相同,把它们归集到一处,不等于可以对齐到同一时点。第三方支付与平台支付有各自的结算窗口与退款时点,本地订单成立时刻与外部流水可见时刻之间必然错开一段;加油卡属预付,卡与账户在体系内流转,可能跨站点消费、跨期核销,其外部流水的归属主体并不等于实际收油的站点;现金则没有外部流水可校,只能靠订单与班次对账。
| 通道类型 | 外部可得的对照物 | 完整性判据 | 主要失真来源 |
|---|---|---|---|
| 第三方支付 | 通道流水与结算明细 | 外部流水条数与本地订单条数的对称差,结算单合计与出票合计是否相等 | 结算窗口错期、退款时点晚于交易 |
| 互联网平台支付 | 平台侧订单与分账记录 | 同上,另加平台订单与本地订单主键的双向覆盖 | 平台改单、部分退款 |
| 加油卡等预付 | 卡系统核销记录 | 核销记录与本地订单的对称差,跨站点归属是否落到正确站点 | 跨期核销、归属主体错位 |
| 现金 | 无外部流水 | 只能按班次封口,比较班次订单快照笔数与出票笔数 | 班次被回改、人工补录 |
现金是四类里唯一没有第二方数据可比对的一格,因此它的完整性只能建立在班次封口这个动作上:一班一结,封口时输出订单快照,快照笔数与出票笔数直接比对。班次一旦允许事后回改,这一格就彻底失去判据。所以在这套设计里,班次封口不可回改是比任何接口约定都硬的约束。
对称差这个判据值得多说一句。用条数相比是错的:通道多回一条、重复回一条,都会被条数相等掩盖过去。要按标识集合比对,并且把“外部返回了本地映射表里没有的流水”也当作一类异常单列,这类异常常常就是无主票据的来源。
四、缺票与多票,一条恒等式四个数
把四个数摆在一起就能判:交易笔数、成功出票笔数、失败笔数、待开笔数。任何时刻,后三者之和必须等于交易笔数。等式成立时两种异常都有确定读法:有交易没有归宿即缺票,一个订单主键对应两张及以上票即多票。等式本身失效时,说明统计口径出了问题,此时任何结论都不该采信,先修口径再谈缺票。
等式失效有三类真实成因,各有对应的可观测点。事件丢失:交易完成事件没有进入待开队列,特征是队列写入次数持续小于订单写入次数,可观测点在事件写入的返回码与本地事务日志,处置是把事件写入与订单落库放进同一个本地事务,用表驱动而不是仅靠消息投递。外部接口超时未回写:票实际已出,本地仍记为待开,特征是待开笔数长期不降而服务侧查得到票号,可观测点是两侧按主键比对的结果,处置是补录而不是重发请求。人工补录未进队列:有人绕过队列直接补了一张票,特征是出票笔数与队列已开笔数不等,可观测点在补录入口有没有留痕,处置是人工补录必须回写队列状态并标记来源。
第三类最难发现,因为它让读数看起来是健康的。还有一条容易被省掉的约束:这条恒等式要按通道、按站点分别成立,只在总量上成立不够用——一个站点的缺票,完全可以在总量上被另一个站点的多票抵消掉。分面成立才是逐笔对应的真正证明。
五、冲正换开要定顺序,失败降级要留人工出口
退款、部分退款、重打发票与跨日红冲各有先后条件,而这些条件必须由配置决定,不能由默认行为决定。
最需要显式化的是退款与发票的相对顺序。票先于款退,会出现款未退而票已冲的状态,账上短暂多出一笔应退未退;款先于票退,会出现款已退而票仍在的形态,对账里被读成多票。两种顺序本身都算不上错,错的是隐式选择:不同通道各走一边,归集后的恒等式必然对不上。因此退款与冲正的先后要作为站点与集团两层都可见的配置项,写明取值、生效范围与变更责任人。重打发票不产生新的交易事实,它应当复用原主键并留下重打记录,而不是新建一条待开。
开票服务不可用时,业务侧要能继续完成加油而把出票排队。理由在场景本身:车辆在现场、计量已经完成、款项已经收讫,因为出票失败就拒绝交易并不成立,它把一段外部系统故障转嫁成了现场冲突,也让计量事实与交易记录出现断层。排队必须有上限策略,三件都要写清:排多久、最多堆多少笔、超限之后走哪条路,是转人工补录还是延后集中处理。上限策略的意义在于把排队从一句无限期的承诺变成一个可判定的状态。超限后的出口必须是人工且动作回写队列,否则就又造出第四成因那类读数失真。
六、责任要落到一个可判定对象上
归集之后最难的事不是接口,而是“谁对缺票负责”这句话有没有对应的对象。四方各管一段:站点侧管现场计量事实、订单生成、班次封口与现金那格的对账;集团侧管通道归集、主键规则与冲正顺序这类配置;开票服务方管自身可用性、回执质量以及服务侧记录与本地记录的差异清理;支付通道方管外部流水与结算明细的完整性和提供时点。证据链在每一方的留存对象与留存期要分别写清。这里有一条常被忽略的推论:整套体系能追溯多久,取决于留存期最短的那一环,而不是最长的那一环。
企业承接这类改造时的三项最低交付是固定的。第一项是映射表与数据字典,含三条标识线的字段来源、主键选择的依据、两类异常的允许条件。第二项是双向对账脚本,从交易查票与从票查交易两个方向都能独立运行,输出缺票、多票、无主票三类结论。第三项是异常台账,逐笔记录异常类型、发现时点、处置动作与关闭证据。
验收只看三条判定:缺票可发现,即在给定窗口内能让等式异常暴露出来;多票可定位,即能指认到具体订单主键与票号,而不是只报一个总数;任意一笔交易能在有限步内追到票号,且步数上限写进文档。三条都能由对方独立复算,不依赖任何一方的口头保证。洹拓在几笔多通道归集的改造里把这三条当成交付基线,因为“交易即开票”里最贵的从来不是把接口接通,而是当两条流水在两个系统里各自生长之后,仍然有一个对象能对一笔加油给出唯一答案。