diff --git a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js index 07ce039..2a93a89 100644 --- a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js +++ b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js @@ -12,6 +12,10 @@ * 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。 * ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。 * ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。 + * ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序, + * 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。 + * ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分), + * 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。 * ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置, * 关键词一般是「异常」,缺了会被拒(errcode 310000)。 * ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重, @@ -95,6 +99,79 @@ function buildDetailLine(row, index) { return `${index}. ${parts.join('|')}`; } +/** + * 按仓分桶:组间按异常包裹数降序(与上面「按仓(异常包裹)」块一致),数量相同按首次出现顺序。 + * 组内保持服务层原顺序(已按 pendingHours 降序,压得最久的排最前),别在这里再排。 + */ +function bucketRowsByWarehouse(rows) { + const buckets = new Map(); + const firstSeen = new Map(); + (Array.isArray(rows) ? rows : []).forEach((row, index) => { + const key = cleanCell(row?.warehouseName, '未知仓'); + if (!buckets.has(key)) { + buckets.set(key, []); + firstSeen.set(key, index); + } + buckets.get(key).push(row); + }); + + return Array.from(buckets.entries()) + .map(([key, items]) => ({ key, items, index: firstSeen.get(key) })) + .sort((a, b) => b.items.length - a.items.length || a.index - b.index); +} + +/** + * 明细按仓聚集:同一个仓的行排在一起(10-22 业务要求,便于各仓在群里只看自己那一段)。 + * - 组间:按该仓异常包裹数降序;组内:保持服务层原顺序(时长降序)。 + * - 仓名取 `warehouseName`,缺失归到「未知仓」一组,避免 undefined 行被拆成散行。 + */ +function groupRowsByWarehouse(rows) { + return bucketRowsByWarehouse(rows).flatMap((group) => group.items); +} + +/** + * 按仓分配明细额度,返回**已按仓聚集**的行。 + * ⚠️ 不能直接 `rows.slice(0, N)`:某个仓异常特别多时会一口气吃满额度, + * 真数据就出现过 40 条明细全是一个仓、其它仓一条都看不到(10-22 实测)。 + * 规则: + * ① 仓数 ≥ 额度 → 每仓 1 条,砍掉的是异常最少的仓; + * ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法,保证总数正好等于额度)。 + */ +function pickDetailRows(rows, limit) { + const groups = bucketRowsByWarehouse(rows); + const total = groups.reduce((sum, group) => sum + group.items.length, 0); + const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total)); + + if (quota === 0) return []; + if (quota >= total) return groups.flatMap((group) => group.items); + if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]); + + const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 })); + const remaining = quota - groups.length; + const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0); + + if (remaining > 0 && leftTotal > 0) { + alloc.forEach((item) => { + item.exact = (item.left / leftTotal) * remaining; + item.extra = Math.min(item.left, Math.floor(item.exact)); + }); + let assigned = alloc.reduce((sum, item) => sum + item.extra, 0); + // 取整剩下的零星额度给「小数部分最大」的仓,避免白浪费额度 + [...alloc] + .sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact))) + .forEach((item) => { + if (assigned >= remaining || item.extra >= item.left) return; + item.extra += 1; + assigned += 1; + }); + alloc.forEach((item) => { + item.take += item.extra; + }); + } + + return alloc.flatMap((item) => item.group.items.slice(0, item.take)); +} + /** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/ function buildCenterLines(data = {}) { const rows = Array.isArray(data.rows) ? data.rows : []; @@ -180,8 +257,9 @@ function buildNotifyText(data = {}, options = {}) { } const centerLines = totalPackages > 0 ? buildCenterLines(data) : []; - const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1)); - const wantCount = Math.min(maxDetailRows, detailAll.length); + // 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序 + const detailAll = pickDetailRows(rows, maxDetailRows).map((row, index) => buildDetailLine(row, index + 1)); + const wantCount = detailAll.length; // 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出) const buildTail = (listed) => { @@ -418,6 +496,8 @@ module.exports = { buildTypeLine, chinaDateKey, chinaHourMinute, + groupRowsByWarehouse, + pickDetailRows, cleanCell, pushWarehouseLogisticsExceptionNotify, runWarehouseLogisticsExceptionNotifyJob, diff --git a/backend/test/warehouseLogisticsExceptionNotify.test.js b/backend/test/warehouseLogisticsExceptionNotify.test.js index 50c7995..2d60c27 100644 --- a/backend/test/warehouseLogisticsExceptionNotify.test.js +++ b/backend/test/warehouseLogisticsExceptionNotify.test.js @@ -154,6 +154,95 @@ test('明细行:仓|快递单号|快递|异常类型(4 段,不带发 ); }); +test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => { + const rows = [ + { warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' }, + { warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' }, + { warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' }, + { warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' }, + { warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' }, + { warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' }, + ]; + + const grouped = job.groupRowsByWarehouse(rows); + // 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序 + assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']); + // 不丢行、不改行 + assert.equal(grouped.length, rows.length); + + const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text; + // 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来) + const detailBlock = text.split('明细前')[1] || ''; + const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line)); + const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, '')); + + // 同仓必须相邻:切成连续段后不应出现重复仓名 + const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]); + assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`); + assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']); + // 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号) + assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']); +}); + +test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => { + const rows = [ + { warehouseName: '', trackingNo: 'A1' }, + { warehouseName: '睢宁仓', trackingNo: 'B1' }, + { trackingNo: 'A2' }, + ]; + const grouped = job.groupRowsByWarehouse(rows); + // 未知仓 2 条(含 undefined)> 睢宁 1 条 + assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']); +}); + +test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => { + // 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它) + const make = (name, count) => Array.from({ length: count }, (_v, i) => ({ + warehouseName: name, + trackingNo: `${name}-${i}`, + })); + const rows = [ + ...make('青岗集仓', 100), + ...make('崔桥工厂仓', 20), + ...make('睢宁仓', 8), + ...make('新苏北仓', 6), + ...make('横林二仓', 6), + ]; + + const picked = job.pickDetailRows(rows, 40); + assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)'); + + const counts = picked.reduce((acc, row) => { + acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1; + return acc; + }, {}); + // 每个仓都要露面,否则群里那个仓的人看不到自己的单 + for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) { + assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`); + } + // 大仓仍然拿得多,但不能吃满额度 + assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度'); + assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细'); + + // 同仓仍然相邻 + const names = picked.map((row) => row.warehouseName); + const runs = names.filter((name, i) => name !== names[i - 1]); + assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`); +}); + +test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => { + const make = (name, count) => Array.from({ length: count }, (_v, i) => ({ + warehouseName: name, + trackingNo: `${name}-${i}`, + })); + const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)]; + const picked = job.pickDetailRows(rows, 2); + assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']); + // 额度 0 / 超额度的边界 + assert.deepEqual(job.pickDetailRows(rows, 0), []); + assert.equal(job.pickDetailRows(rows, 99).length, 17); +}); + test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => { const data = sampleData({ rows: [],