☰
1688 批量申请发票:把订单按商家归并成消息,数据是这么组织的,多多开票助手
2026/10/3 6:51:03 网站建设 项目流程

这件事的难点不在发送,在整理

手头这个扩展是我自己写的,装在浏览器上,主要处理网店订单和发票这条线的事,名字叫多多开票助手,官网:duoduoke.net。拼多多那边申请发票,是走进开票页点提交,动作有明确回执。1688 这边不一样,平台没有给买家一个统一的申请入口,想要发票,只能去站内消息里跟商家说。换句话说,买家这一侧的「申请」,落到代码里就是一段发出去的消息。

发送本身不是难事,难的是发出去之前那一段。要开的单可能散在几十页、几十家商家手里,每一家的抬头、订单号、金额都得凑齐,一条消息对应一家。这一段我前后重写过三版,这篇把它拆开讲。

有一个前提要先讲清楚:1688 买家这一侧没有统一的发票申请入口,所谓申请,落到实处就是一段发给商家的站内消息。这个前提决定了整件事的形状,也决定了下面所有设计都围着「消息」转。

先把不能开票的单挑出去

不是所有订单都能开。交易关闭的、退款完成的、还在售后的,这些单发过去商家也没法处理,白白多一条消息。所以在归并之前先过一遍状态。

有一种情况要单独拎出来:整单退款和部分退款不是一回事。

export function evaluate1688OrderEligibility(orderStatusText, itemStatusTexts = []) { const orderExclusion = get1688OrderExclusion(orderStatusText); const itemCount = itemStatusTexts.length; const refundedItemCount = itemStatusTexts.filter(t => Boolean(get1688OrderExclusion(t))).length; if (orderExclusion) return { exclusion: orderExclusion, partialRefund: false }; // 一个单子里所有商品都退了,整单不可用 if (itemCount > 0 && refundedItemCount === itemCount) { return { exclusion: { status: '全部商品已退款或售后' }, partialRefund: false }; } // 只有一部分退了:保留,但打个标记 return { exclusion: null, partialRefund: refundedItemCount > 0, refundedItemCount, itemCount }; }

这里有个我踩过的坑。订单状态的文案有好几种写法,不是一个规整的枚举:退款有「退款成功」「退款完成」「已退款」,售后有「售后中」「售后完成」「售后关闭」。我第一版只覆盖了其中两个,结果一批本该排除的单混进了队列,发出去以后被商家退回来。

改法是把这些文案收成一张表,用includes做包含匹配,而不是等值比对。等值匹配在文案变体面前就是筛子,页面每换一次措辞就得补一条。

按商家归并:一个 Map 就够

挑完之后是归并。同一个商家可能这个月下了十单,要是每单发一条,等于在人家聊天框里刷十条,抬头税号重复十遍。正确的做法是先按商家聚起来,一家一条。

export function groupOrdersByMerchant(orders) { const groups = new Map(); orders.forEach(order => { if (!groups.has(order.merchantNick)) groups.set(order.merchantNick, []); groups.get(order.merchantNick).push({ ...order }); }); return groups; }

merchantNick是商家在订单卡片上的旺旺标识,同一家只要标识一致就能聚到一起。这里我特意做了浅拷贝,否则两个商家若共享同一个对象引用,后面改一单的状态另一条也跟着变,排查起来很费劲。

一条消息最多装 10 笔,超了拆批

归并之后有个上限问题:一家商家如果有一百笔,全塞进一条消息,聊天框会把它折叠起来,商家根本不会点开看。所以每条消息有笔数上限。

export const MAX_ORDERS_PER_MESSAGE = 10; export function createMerchantBatches(orders, size = MAX_ORDERS_PER_MESSAGE) { const batches = []; groupOrdersByMerchant(orders).forEach((merchantOrders, merchantNick) => { const total = Math.ceil(merchantOrders.length / size); for (let index = 0; index < total; index += 1) { batches.push({ merchantNick, orders: merchantOrders.slice(index * size, (index + 1) * size), batchIndex: index + 1, batchTotal: total, batchLabel: `${index + 1}/${total}`, }); } }); return batches; }

为什么是 10,而不是 20 或 30,这是个取舍。10 笔加上抬头信息,差不多是一屏能看完的长度;再长就要滑动,商家在手机上更不会细看。而且一条消息对应一次发送尝试,装得太满,中间出一次错整批都得重来。10 是我拿实际聊天记录试出来的数,不是算出来的。

超过 10 笔的会拆成多条,每条开头带上[分批申请 1/3]这样的序号,商家一看就明白这是同一批里的一条。少了这个序号,对方收到三条相似的消息,大概率只会回你一句「到底几单」。

消息怎么拼:抬头、订单、合计、时间范围

一条消息里要装四样东西,顺序不能乱。

  • 发票信息:票种、抬头、税号,要专票再加注册地址、电话、开户行和账号
  • 订单汇总:总金额、总笔数
  • 订单明细:一行一笔,订单号加金额
  • 时间范围:这批单从哪天到哪天

拼装我写成了一个纯函数,输入抬头和订单数组,输出一段字符串,中间不碰 DOM。好处是能单独测,改一处不至于影响全流程。

const amounts = orders.map(o => toAmount(o.amount)).filter(a => a != null); const summary = amounts.length ? `总订单金额:${amounts.reduce((a, b) => a + b, 0).toFixed(2)},总订单数:${orders.length}` : `总订单数:${orders.length}`;

这里的toAmount是过滤器,不是转换器。有些订单的金额字段是空的,如果直接Number(undefined)就是 NaN,加进合计那一行会变成「总订单金额:NaN」。我最早没挡这个,商家收到一条写着 NaN 的消息,回头问我这个金额是不是有问题。后来改成求和之前先把非数值的项剔掉,一笔有效金额都没有的时候,干脆不写总金额这一行。

判断这条发过了:先归一化,再算哈希

到这一步消息拼好了,但还剩一个问题:怎么知道同一份内容发过没有。用户点两次开始,或者同一批单今天发过、明天又发,都不该重复。

直接拿消息全文比对有两个麻烦。一条消息几百上千字符,存下来占地方;更麻烦的是,「看起来一样」的文本未必真的相等。

第二个麻烦是我实际撞上的。我在本地拼出来的字符串,行尾是干净的;可同一段文本从输入框里读回来,可能带上了行尾空格,换行符也从\n变成了\r\n。两段人眼一模一样的内容,直接比是对不上的,去重等于没做。

所以先归一化,再算一个短哈希。

export function normalizeMessage(value) { return String(value || '') .replace(/\r\n?/g, '\n') // 换行统一成 \n .split('\n').map(line => line.trimEnd()) // 去掉每行行尾空白 .join('\n').trim(); } export function stableMessageHash(value) { const text = normalizeMessage(value); let hash = 0x811c9dc5; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 0x01000193); // FNV-1a } return (hash >>> 0).toString(16).padStart(8, '0'); }

归一化把格式上的差异抹掉,只留下内容上的差异。这样行尾空白、换行符类型都不影响哈希,只有抬头或订单真的变了,哈希才跟着变。FNV-1a 很短,八位十六进制,够用,也不用为了它引一个依赖。

历史记录里存的不是全文,是一条三元组:订单号、抬头 id、消息哈希。三个都对上,才判定这条发过了。

export function hasSuccessfulDuplicate(entries, candidate) { return entries.some(entry => entry.status === 'sent' && entry.orderId === candidate.orderId && entry.invoiceTitleId === candidate.invoiceTitleId && entry.messageHash === candidate.messageHash ); }

抬头 id 要一起进比对,是因为同一批订单换个抬头再发一次是合理需求,不该被判成重复。只看订单号会把这种正常操作误伤掉。

同一家出现在两页,会生成两条消息

再说一个容易被当成 bug 的行为。

1688 的订单列表是分页的,这个扩展按当前页读。如果同一家商家的单分别落在第 2 页和第 5 页,翻到第 5 页时会重新为这家生成一条消息。结果是同一家收到两条,两条对应的订单不一样。

这不是重复发送。它按页组织,每一页是独立的一批。要做到跨页去重,就得先把所有页读完,在内存里维护一张全局索引,中途断了还要能恢复。代价是每次都要扫完几十页,慢,而且任何一页失败整批就不完整。

我选了按页来。理由很直接:买家自己清楚这批单是分几页翻的,消息对得上一页一页的直觉;而全局去重带来的那点复杂度,换来的只是少几条消息,不划算。

export function buildSentOrderIndex(entries) { return new Set( entries.filter(e => e.status === 'sent' && e.orderId).map(e => e.orderId) ); }

发送记录默认保留 180 天,超期的自动清掉。这个长度是照着一年的对账周期砍半定的,再长意义不大,存储也会涨。

小结

这套东西从头到尾没有多难的技术,难在把「一家家发消息」这个模糊的动作,拆成可描述、可判断的几步:哪些单该发、归到哪一家、一条装几笔、这份内容是不是发过了。

拆开之后有个好处,每一步都能单独验证。归并错了看分组,消息拼错了看那段纯函数,重复发了看哈希和历史。反过来,如果当初写成一大坨「遍历订单挨个发」,出问题时只能从头猜。

边界也得说清楚。它整理的是发出去之前那一段,消息出去之后的事它管不了:商家什么时候看、开不开、开成什么样,都在对方手里。把能确定的部分做干净,把不确定的部分如实交出来,这是它全部的野心。

还有一处维护成本一直存在。页面的订单卡片结构改一次,选择器就要跟着改一次。这类跑在别人页面上的自动化,没有一劳永逸。


关键词:1688批量申请发票, 订单归并, 浏览器扩展, 消息去重, FNV哈希, 分批发送, MV3, 电商自动化

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询