业务三次要求(2026-10-22):明细最后面的超时时间也不要。 - buildDetailLine 现在是 4 段:仓|快递单号|快递|异常类型 - 删除仅此处使用的 formatHours()(不留死代码) - 「无快递单号时退回内部单号」的兜底按业务确认保留 - 文件头/函数注释补第三条口径,单测断言正好 4 段且不含 17.3h 这类时长
426 lines
16 KiB
JavaScript
426 lines
16 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* 业务口径(2026-10-22 定稿):
|
||
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
|
||
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
|
||
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
|
||
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
|
||
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
|
||
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
|
||
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
|
||
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
|
||
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
|
||
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
|
||
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
|
||
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
|
||
*
|
||
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
|
||
*/
|
||
|
||
const cron = require('node-cron');
|
||
const { getLogisticsExceptionExportRows } = require('../services/warehouseLogisticsExceptionService');
|
||
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
||
const {
|
||
DEFAULT_MAX_DETAIL_ROWS,
|
||
DEFAULT_PAGE_URL,
|
||
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
|
||
isWarehouseLogisticsExceptionNotifyReady,
|
||
loadWarehouseLogisticsExceptionNotifyState,
|
||
saveWarehouseLogisticsExceptionNotifyState,
|
||
} = require('../services/warehouseLogisticsExceptionNotifySettings');
|
||
|
||
const TIMEZONE = 'Asia/Shanghai';
|
||
// 每分钟醒一次,比对「现在是不是配置的推送时刻」—— 这样业务在页面上改时间不用重启后端
|
||
const DEFAULT_TICK_CRON = '* * * * *';
|
||
// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败
|
||
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
||
|
||
let tickRunning = false;
|
||
|
||
function pad2(value) {
|
||
return String(value).padStart(2, '0');
|
||
}
|
||
|
||
/** 北京时间的 YYYY-MM-DD */
|
||
function chinaDateKey(date = new Date()) {
|
||
const base = date instanceof Date ? date : new Date(date);
|
||
if (!Number.isFinite(base.getTime())) return '';
|
||
return new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: TIMEZONE,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
}).format(base);
|
||
}
|
||
|
||
/** 北京时间的 HH:mm —— 用来跟配置里的 sendTime 比对 */
|
||
function chinaHourMinute(date = new Date()) {
|
||
const base = date instanceof Date ? date : new Date(date);
|
||
if (!Number.isFinite(base.getTime())) return '';
|
||
return new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: TIMEZONE,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
}).format(base);
|
||
}
|
||
|
||
/**
|
||
* 字段清洗:换行/连续空白压成单空格。
|
||
* ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。
|
||
*/
|
||
function cleanCell(value, fallback = '') {
|
||
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||
return text || fallback;
|
||
}
|
||
|
||
/**
|
||
* 明细一行:仓|快递单号|快递|异常类型
|
||
* ⚠️ 业务明确要求明细**不带发货时间**(10-22 反馈),别再加 sendTime 回去 ——
|
||
* 仓库看单子只需知道「哪个单、什么异常」,时间类信息一律到页面上看。
|
||
* ⚠️ 第二段用**快递单号**(`trackingNo` = 上游 `logistic_id`),不是内部单号 `oId`(10-22 二次要求)。
|
||
* 「未揽收 / 无物流单」这类包裹本来就没有快递单号 → 退回内部单号,否则群里这条无法定位。
|
||
* ⚠️ 末尾的「待处理小时数」(`pendingHours`)也已按 10-22 三次要求去掉,别再加回来。
|
||
*/
|
||
function buildDetailLine(row, index) {
|
||
const parts = [
|
||
cleanCell(row?.warehouseName, '未知仓'),
|
||
cleanCell(row?.trackingNo || row?.oId, '无单号'),
|
||
cleanCell(row?.carrier, '未知快递'),
|
||
cleanCell(row?.exceptionLabel, '异常'),
|
||
];
|
||
return `${index}. ${parts.join('|')}`;
|
||
}
|
||
|
||
/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/
|
||
function buildCenterLines(data = {}) {
|
||
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||
const packagesByCenter = rows.reduce((acc, row) => {
|
||
const key = String(row?.centerKey || '');
|
||
if (!key) return acc;
|
||
acc[key] = (acc[key] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
|
||
return (data.centers || [])
|
||
.map((center) => ({
|
||
name: cleanCell(center.name || center.key, '未知仓'),
|
||
exceptionPackages: packagesByCenter[center.key] || 0,
|
||
shippedOrders: Number(center.shippedOrders) || 0,
|
||
}))
|
||
.filter((item) => item.exceptionPackages > 0)
|
||
.sort((a, b) => b.exceptionPackages - a.exceptionPackages)
|
||
.slice(0, 12)
|
||
.map((item, index) => `${index + 1}. ${item.name} ${item.exceptionPackages} 个(发货 ${item.shippedOrders} 单)`);
|
||
}
|
||
|
||
function buildTypeLine(data = {}) {
|
||
const summary = data.summary || {};
|
||
const entries = Object.entries(summary.byType || {});
|
||
if (!entries.length) return '';
|
||
return entries
|
||
.map(([type, count]) => {
|
||
const meta = (data.types || []).find((item) => item.key === type);
|
||
return { label: meta?.label || type, count: Number(count) || 0 };
|
||
})
|
||
.sort((a, b) => b.count - a.count)
|
||
.map((item) => `${item.label} ${item.count}`)
|
||
.join(' · ');
|
||
}
|
||
|
||
function buildPageLink(pageUrl, verifyDate) {
|
||
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
||
if (!verifyDate) return base;
|
||
const glue = base.includes('?') ? '&' : '?';
|
||
return `${base}${glue}date=${encodeURIComponent(verifyDate)}`;
|
||
}
|
||
|
||
/**
|
||
* 组装群消息正文(纯函数,便于单测)。
|
||
* 结构:抬头 → 数字 → 分类型 → 按仓 → 明细前 N 条 → 页面链接
|
||
* 超长处理:从明细末尾往前砍,砍到能发为止(绝不让钉钉 400 把整条消息吞掉)。
|
||
*
|
||
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
|
||
*/
|
||
function buildNotifyText(data = {}, options = {}) {
|
||
const batch = data.batch || {};
|
||
const summary = data.summary || {};
|
||
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||
const exclusions = data.exclusions || {};
|
||
|
||
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
|
||
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
|
||
: DEFAULT_MAX_DETAIL_ROWS;
|
||
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, batch.verifyDate);
|
||
|
||
const totalPackages = Number(summary.exceptionPackages) || rows.length;
|
||
const shipped = Number(summary.shippedOrders) || 0;
|
||
const ratio = Number.isFinite(Number(summary.exceptionRate)) ? `${Number(summary.exceptionRate)}%` : '—';
|
||
|
||
const head = [
|
||
`【仓储物流转运异常】${cleanCell(batch.label, '本批核验')}`,
|
||
`已发货 ${shipped} 单 / ${Number(summary.packageCount) || 0} 个包裹,异常 ${totalPackages} 个包裹(${ratio})`,
|
||
];
|
||
|
||
const typeLine = buildTypeLine(data);
|
||
if (typeLine) head.push(`类型:${typeLine}`);
|
||
|
||
if (totalPackages === 0) {
|
||
head.push('本批无转运异常,全部正常。');
|
||
}
|
||
|
||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||
+ (Number(exclusions.voidedPackages) || 0)
|
||
+ (Number(exclusions.nonRealPackages) || 0);
|
||
if (excludedCount > 0) {
|
||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
|
||
}
|
||
|
||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||
const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1));
|
||
const wantCount = Math.min(maxDetailRows, detailAll.length);
|
||
|
||
// 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出)
|
||
const buildTail = (listed) => {
|
||
const lines = [];
|
||
if (totalPackages > 0) {
|
||
if (listed === 0) {
|
||
lines.push('');
|
||
lines.push(`明细共 ${totalPackages} 条,消息里不放,请点下面链接查看。`);
|
||
} else if (totalPackages > listed) {
|
||
lines.push(`…其余 ${totalPackages - listed} 条见页面。`);
|
||
}
|
||
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
|
||
}
|
||
return lines;
|
||
};
|
||
|
||
const assemble = (listed, withCenters, extraNote = '') => {
|
||
const blocks = [...head];
|
||
if (withCenters && centerLines.length) {
|
||
blocks.push('');
|
||
blocks.push('按仓(异常包裹):');
|
||
blocks.push(...centerLines);
|
||
}
|
||
if (listed > 0) {
|
||
blocks.push('');
|
||
blocks.push(`明细前 ${listed} 条:`);
|
||
blocks.push(...detailAll.slice(0, listed));
|
||
}
|
||
const tail = buildTail(listed);
|
||
if (tail.length) blocks.push(...tail);
|
||
if (extraNote) blocks.push(extraNote);
|
||
const text = blocks.join('\n');
|
||
return { text, bytes: Buffer.byteLength(text, 'utf8') };
|
||
};
|
||
|
||
// 先按期望条数拼;超长就从明细末尾往前砍(先砍明细,再砍分仓块)
|
||
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」——
|
||
// 后者是正常行为(listedCount < rowCount),混在一起会让页面误报「超长精简」。
|
||
let listed = wantCount;
|
||
let withCenters = centerLines.length > 0;
|
||
let result = assemble(listed, withCenters);
|
||
let truncated = false;
|
||
|
||
while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) {
|
||
listed -= 1;
|
||
truncated = true;
|
||
result = assemble(listed, withCenters);
|
||
}
|
||
if (result.bytes > SAFE_CONTENT_BYTES && withCenters) {
|
||
withCenters = false;
|
||
result = assemble(listed, withCenters);
|
||
}
|
||
if (result.bytes > SAFE_CONTENT_BYTES) {
|
||
// 极端情况(仓名/链接特别长):硬截断,至少保证消息能发出去
|
||
const chars = Array.from(result.text);
|
||
let bytes = 0;
|
||
let cut = '';
|
||
for (const char of chars) {
|
||
const size = Buffer.byteLength(char, 'utf8');
|
||
if (bytes + size > SAFE_CONTENT_BYTES) break;
|
||
bytes += size;
|
||
cut += char;
|
||
}
|
||
result = { text: cut, bytes };
|
||
truncated = true;
|
||
}
|
||
|
||
return {
|
||
text: result.text,
|
||
rowCount: totalPackages,
|
||
listedCount: listed,
|
||
bytes: result.bytes,
|
||
truncated,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 执行一次推送。参数:
|
||
* now 指定时间(测试用)
|
||
* date 指定核验日 'YYYY-MM-DD'
|
||
* force 忽略「今天已经推过」的限制(页面「立即推送」用)
|
||
* dryRun 只组装消息不发送(预览 / 单测用)
|
||
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
||
*/
|
||
async function pushWarehouseLogisticsExceptionNotify(options = {}) {
|
||
const now = options.now === undefined ? new Date() : new Date(options.now);
|
||
const pushDate = chinaDateKey(now);
|
||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
|
||
|
||
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
||
if (options.probe === true) {
|
||
if (!isWarehouseLogisticsExceptionNotifyReady(config)) {
|
||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||
}
|
||
const delivery = await sendTextToWebhook({
|
||
webhookUrl: config.webhookUrl,
|
||
secret: config.webhookSecret,
|
||
content: '【仓储物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。',
|
||
});
|
||
return { sent: true, probe: true, delivery };
|
||
}
|
||
|
||
const state = await loadWarehouseLogisticsExceptionNotifyState();
|
||
if (state?.lastSentDate === pushDate && options.force !== true) {
|
||
return { sent: false, reason: 'already_sent_today', pushDate, lastSentAt: state.lastSentAt || null };
|
||
}
|
||
if (!config.enabled && options.force !== true) {
|
||
return { sent: false, reason: 'disabled' };
|
||
}
|
||
|
||
const dryRun = options.dryRun === true;
|
||
if (!dryRun && !isWarehouseLogisticsExceptionNotifyReady(config)) {
|
||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||
}
|
||
|
||
const data = await getLogisticsExceptionExportRows({ date: options.date });
|
||
if (data?.notStarted) {
|
||
// 还没到核验时刻(例如把推送时间设在 09:00 之前)→ 不推空数据,等下一轮或人工
|
||
return { sent: false, reason: 'not_started', batch: data.batch || null };
|
||
}
|
||
|
||
const built = buildNotifyText(data, {
|
||
maxDetailRows: config.maxDetailRows,
|
||
pageUrl: config.pageUrl,
|
||
});
|
||
|
||
if (dryRun) {
|
||
return {
|
||
sent: false,
|
||
reason: 'dry_run',
|
||
text: built.text,
|
||
rowCount: built.rowCount,
|
||
listedCount: built.listedCount,
|
||
bytes: built.bytes,
|
||
truncated: built.truncated,
|
||
batch: data.batch || null,
|
||
summary: data.summary || null,
|
||
};
|
||
}
|
||
|
||
const delivery = await sendTextToWebhook({
|
||
webhookUrl: config.webhookUrl,
|
||
secret: config.webhookSecret,
|
||
content: built.text,
|
||
atAll: config.atAll === true,
|
||
});
|
||
|
||
await saveWarehouseLogisticsExceptionNotifyState({
|
||
lastSentDate: pushDate,
|
||
lastSentAt: new Date().toISOString(),
|
||
lastStatus: 'sent',
|
||
lastError: '',
|
||
rowCount: built.rowCount,
|
||
listedCount: built.listedCount,
|
||
bytes: built.bytes,
|
||
truncated: built.truncated,
|
||
verifyDate: data.batch?.verifyDate || '',
|
||
});
|
||
|
||
return {
|
||
sent: true,
|
||
rowCount: built.rowCount,
|
||
listedCount: built.listedCount,
|
||
bytes: built.bytes,
|
||
truncated: built.truncated,
|
||
text: built.text,
|
||
batch: data.batch || null,
|
||
summary: data.summary || null,
|
||
delivery,
|
||
};
|
||
}
|
||
|
||
async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', options = {}) {
|
||
try {
|
||
const result = await pushWarehouseLogisticsExceptionNotify(options);
|
||
if (result.sent) {
|
||
console.log(`[WleNotify] ${source} 已推送(${result.rowCount} 条异常,列出 ${result.listedCount} 条,${result.bytes} 字节)`);
|
||
} else {
|
||
console.log(`[WleNotify] ${source} 跳过: ${result.reason}`);
|
||
}
|
||
return result;
|
||
} catch (error) {
|
||
console.error(`[WleNotify] ${source} 执行失败:`, error.message || error);
|
||
try {
|
||
await saveWarehouseLogisticsExceptionNotifyState({
|
||
...(await loadWarehouseLogisticsExceptionNotifyState()),
|
||
lastError: String(error.message || error).slice(0, 500),
|
||
lastFailedAt: new Date().toISOString(),
|
||
});
|
||
} catch (_ignored) { /* 状态写失败不影响主流程 */ }
|
||
return { sent: false, reason: 'error', error: error.message || String(error) };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。
|
||
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
|
||
*/
|
||
function startWarehouseLogisticsExceptionNotifyScheduler() {
|
||
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
|
||
if (disabled) {
|
||
console.log('[Scheduler] 物流转运异常钉钉推送已禁用');
|
||
return;
|
||
}
|
||
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
||
cron.schedule(expression, () => {
|
||
if (tickRunning) return;
|
||
tickRunning = true;
|
||
(async () => {
|
||
try {
|
||
const now = new Date();
|
||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
|
||
if (!config.enabled) return;
|
||
if (chinaHourMinute(now) !== config.sendTime) return;
|
||
await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now });
|
||
} catch (error) {
|
||
console.error('[WleNotify] 定时检查失败:', error.message || error);
|
||
} finally {
|
||
tickRunning = false;
|
||
}
|
||
})();
|
||
}, { timezone: TIMEZONE });
|
||
|
||
console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`);
|
||
}
|
||
|
||
module.exports = {
|
||
DEFAULT_TICK_CRON,
|
||
SAFE_CONTENT_BYTES,
|
||
TIMEZONE,
|
||
buildCenterLines,
|
||
buildDetailLine,
|
||
buildNotifyText,
|
||
buildTypeLine,
|
||
chinaDateKey,
|
||
chinaHourMinute,
|
||
cleanCell,
|
||
pushWarehouseLogisticsExceptionNotify,
|
||
runWarehouseLogisticsExceptionNotifyJob,
|
||
startWarehouseLogisticsExceptionNotifyScheduler,
|
||
};
|