Merge pull request 'feat(wle): 钉钉推送明细按仓聚集,并给每个仓保底 1 条' (#79)
This commit is contained in:
commit
88913260a3
@ -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,
|
||||
|
||||
@ -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: [],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user