cangku/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js
yanhui 7939816b64 fix(wle): 推送明细再去掉末尾「待处理小时数」
业务三次要求(2026-10-22):明细最后面的超时时间也不要。

- buildDetailLine 现在是 4 段:仓|快递单号|快递|异常类型
- 删除仅此处使用的 formatHours()(不留死代码)
- 「无快递单号时退回内部单号」的兜底按业务确认保留
- 文件头/函数注释补第三条口径,单测断言正好 4 段且不含 17.3h 这类时长
2026-10-22 11:56:11 +08:00

426 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};