705 lines
31 KiB
JavaScript
705 lines
31 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* 业务口径(2026-10-22 定稿):
|
||
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
|
||
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
|
||
* ⚠️ 失败必须重试(2026-11-11 加):09:10 起 30 分钟内每 5 分钟再试一次,成功即停;
|
||
* 窗口过了不再补推(上午的数据下午才发会误导业务)。见 `resolveScheduledPushDecision`。
|
||
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
|
||
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
|
||
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
|
||
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
|
||
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
|
||
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
|
||
* ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序,
|
||
* 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。
|
||
* ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分),
|
||
* 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。
|
||
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
|
||
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
|
||
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
|
||
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
|
||
* ⑤ 数据新鲜度必须进正文(2026-11-11 加):只要算出异常,就要说明物流快照
|
||
* 是否覆盖本轮核验时刻、是否过旧 —— 否则「990 单 100% 异常」到底是真异常
|
||
* 还是镜像表停在昨天,群里看不出来。见 `buildFreshnessWarning`。
|
||
*
|
||
* 时区:生产容器是 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;
|
||
|
||
// 🔥 2026-11-11 加:推送失败要重试,不能「一天只有一次机会」。
|
||
// 实况:11-11 09:10 那次撞上业务库连不上(状态里 `Failed to connect to 218.93.9.21:1433`),
|
||
// 老逻辑只在 `HH:mm === sendTime` 这一分钟内跑一次,失败后当天彻底不推 —— 群里一整天没消息。
|
||
// 现在改成:从推送时刻起 `RETRY_WINDOW_MINUTES` 分钟内,每隔 `RETRY_INTERVAL_MINUTES` 分钟再试,
|
||
// 成功即停(`lastSentDate` 落库后后续 tick 自动 skip),窗口过了就不再补(避免上午的数据下午才发)。
|
||
// 两个值都可用环境变量覆盖,现网想调不用改前端配置、也不用发版。
|
||
const DEFAULT_RETRY_WINDOW_MINUTES = 30;
|
||
const DEFAULT_RETRY_INTERVAL_MINUTES = 5;
|
||
|
||
function readPositiveMinutes(value, fallback, max) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num) || num <= 0) return fallback;
|
||
return Math.min(Math.round(num), max);
|
||
}
|
||
|
||
const RETRY_WINDOW_MINUTES = readPositiveMinutes(
|
||
process.env.WLE_NOTIFY_RETRY_WINDOW_MINUTES,
|
||
DEFAULT_RETRY_WINDOW_MINUTES,
|
||
12 * 60
|
||
);
|
||
const RETRY_INTERVAL_MINUTES = readPositiveMinutes(
|
||
process.env.WLE_NOTIFY_RETRY_INTERVAL_MINUTES,
|
||
DEFAULT_RETRY_INTERVAL_MINUTES,
|
||
RETRY_WINDOW_MINUTES
|
||
);
|
||
|
||
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);
|
||
}
|
||
|
||
/** 'HH:mm' → 当天第几分钟(09:10 → 550)。非法输入返回 null,调用方按「不跑」处理 */
|
||
function minuteOfDay(hhmm) {
|
||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(hhmm || '').trim());
|
||
if (!match) return null;
|
||
return Number(match[1]) * 60 + Number(match[2]);
|
||
}
|
||
|
||
/**
|
||
* 定时器每一分钟问一次「现在该不该推」。纯函数,便于把时间窗口/节流的边界钉死在单测里。
|
||
*
|
||
* 返回 `{ run, reason, pushDate, attempt }`:
|
||
* · `already_sent_today` 今天已推成功 → 不再动(重试自然也停)
|
||
* · `before_time` 还没到配置的推送时刻
|
||
* · `window_closed` 已过「推送时刻 + 重试窗口」,不再补推(避免 10 点失败、下午才发出去)
|
||
* · `wait_interval` 上一轮失败距现在不到重试间隔,等着
|
||
* · `first_round` 到点第一次跑
|
||
* · `retry` 上一轮失败后的第 N 次重试
|
||
*
|
||
* ⚠️ 窗口**不跨日**:`sendTime + window` 超过 23:59 就截到当天最后一分钟。
|
||
* 跨日会让 `pushDate` 换天,去重键(`lastSentDate`)跟着变,容易出现同一天推两遍。
|
||
*/
|
||
function resolveScheduledPushDecision(options = {}) {
|
||
const now = options.now instanceof Date ? options.now : new Date(options.now ?? Date.now());
|
||
const pushDate = chinaDateKey(now);
|
||
const state = options.state && typeof options.state === 'object' ? options.state : {};
|
||
const windowMinutes = readPositiveMinutes(options.retryWindowMinutes, RETRY_WINDOW_MINUTES, 12 * 60);
|
||
const intervalMinutes = readPositiveMinutes(options.retryIntervalMinutes, RETRY_INTERVAL_MINUTES, windowMinutes);
|
||
|
||
if (state.lastSentDate === pushDate) {
|
||
return { run: false, reason: 'already_sent_today', pushDate, attempt: 0 };
|
||
}
|
||
|
||
const nowMinute = minuteOfDay(chinaHourMinute(now));
|
||
const startMinute = minuteOfDay(options.sendTime);
|
||
if (nowMinute === null || startMinute === null) {
|
||
return { run: false, reason: 'bad_send_time', pushDate, attempt: 0 };
|
||
}
|
||
if (nowMinute < startMinute) {
|
||
return { run: false, reason: 'before_time', pushDate, attempt: 0 };
|
||
}
|
||
const endMinute = Math.min(startMinute + windowMinutes, 23 * 60 + 59);
|
||
if (nowMinute > endMinute) {
|
||
return { run: false, reason: 'window_closed', pushDate, attempt: 0 };
|
||
}
|
||
|
||
// 当天的尝试次数:跨天或没有记录都归零,避免沿用昨天的计数把节流算歪
|
||
const attempt = state.attemptDate === pushDate ? Number(state.attemptCount) || 0 : 0;
|
||
if (attempt > 0) {
|
||
const lastAttemptMs = Date.parse(String(state.lastAttemptAt || ''));
|
||
if (Number.isFinite(lastAttemptMs)) {
|
||
const gapMinutes = (now.getTime() - lastAttemptMs) / 60000;
|
||
if (gapMinutes < intervalMinutes) {
|
||
return { run: false, reason: 'wait_interval', pushDate, attempt };
|
||
}
|
||
}
|
||
}
|
||
|
||
return { run: true, reason: attempt > 0 ? 'retry' : 'first_round', pushDate, attempt: attempt + 1 };
|
||
}
|
||
|
||
/**
|
||
* 字段清洗:换行/连续空白压成单空格。
|
||
* ⚠️ 物流描述里常有 `\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('|')}`;
|
||
}
|
||
|
||
/**
|
||
* 按仓分桶:组间按异常包裹数降序(与上面「按仓(异常包裹)」块一致),数量相同按首次出现顺序。
|
||
* 组内保持服务层原顺序(已按 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 : [];
|
||
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(' · ');
|
||
}
|
||
|
||
/**
|
||
* 快照时刻的展示形式:`2026-11-11 09:05:00` → `09:05`;**跨天则带 `MM-DD`**。
|
||
* ⚠️ 只显示 HH:mm 会把「昨天 11:53」读成「今天 11:53」。11-11 那批的 `snapshotAt`
|
||
* 其实是前一天的(镜像表停在 11-10 11:53),群里看到「快照最新只到 11:53」会误以为
|
||
* 当天上午的数据很新鲜 —— 与 WST「历史单审核时间必须带 MM-DD」是同一个坑。
|
||
*/
|
||
function formatSnapshotMoment(value, referenceDate = '') {
|
||
const text = String(value || '').trim();
|
||
if (text.length < 16) return text;
|
||
const datePart = text.slice(0, 10);
|
||
const timePart = text.slice(11, 16);
|
||
const refDate = String(referenceDate || '').trim().slice(0, 10);
|
||
if (refDate && datePart === refDate) return timePart;
|
||
return `${datePart.slice(5)} ${timePart}`;
|
||
}
|
||
|
||
/**
|
||
* 快照新鲜度告警 —— **有异常数据时才用**(0 单的情形由 `buildNotifyText` 里的 emptyWindow 分支处理)。
|
||
*
|
||
* 🔥 2026-11-11 实况:物流镜像表停在 11-10 11:53,09:10 那批算出 990 单**全部异常**,
|
||
* 正文却只有一句「异常 990 个(100%)」。群里没法分辨这是真异常还是数据没更新,
|
||
* 而这恰恰决定当天要不要去催快递。所以「没覆盖核验时刻」「快照过旧」必须写进正文,
|
||
* 不能只留在页面 hint 里 —— 群里干活的人看不到页面。
|
||
*/
|
||
function buildFreshnessWarning(freshness = {}, batch = {}) {
|
||
const info = freshness && typeof freshness === 'object' ? freshness : {};
|
||
const snapshotAt = formatSnapshotMoment(info.snapshotAt, batch.verifyDate);
|
||
|
||
if (info.coversDeadline === false) {
|
||
const deadline = formatSnapshotMoment(batch.deadline, batch.verifyDate);
|
||
return `⚠️ 数据未覆盖核验时刻:物流快照最新只到 ${snapshotAt || '未知'}`
|
||
+ `${deadline ? `,本轮核验 ${deadline}` : ''} —— 之后的揽收/中转轨迹没进库,异常量会偏大。`
|
||
+ '请以聚水潭实时物流为准,别按这个数去催快递。';
|
||
}
|
||
if (info.stale === true) {
|
||
const ageMinutes = Number(info.ageMinutes);
|
||
const hours = Number.isFinite(ageMinutes) && ageMinutes > 0
|
||
? `(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新)`
|
||
: '';
|
||
return `⚠️ 物流快照偏旧:最新只到 ${snapshotAt || '未知'}${hours} —— 状态可能滞后,`
|
||
+ '异常量仅供参考,请以聚水潭实时物流为准。';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
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}`);
|
||
|
||
// 快照新鲜度告警:只对「算出了异常」的批次有意义 —— 11-11 那批 990 单全部异常,
|
||
// 正文却完全看不出数据停在昨天,业务只能凭印象判断要不要催快递。
|
||
const freshness = data.freshness || {};
|
||
const freshnessWarning = totalPackages > 0 ? buildFreshnessWarning(freshness, batch) : '';
|
||
if (freshnessWarning) head.push(freshnessWarning);
|
||
|
||
// 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」——
|
||
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
|
||
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
|
||
if (totalPackages === 0) {
|
||
if (freshness.emptyWindow && freshness.sourceStale) {
|
||
const hours = Number(freshness.sourceAgeMinutes) > 0
|
||
? `(已 ${Math.round((Number(freshness.sourceAgeMinutes) / 60) * 10) / 10} 小时未同步)`
|
||
: '';
|
||
head.push(`⚠️ 物流数据未同步:镜像表最后更新 ${freshness.sourceLastUpdate || '未知'}${hours},`
|
||
+ `${batch.shipDate || '该日'} 的发货记录一条都没进库 —— 上面 0 单不代表当天没发货,请先到聚水潭核对。`);
|
||
} else if (freshness.emptyWindow) {
|
||
head.push(`${batch.shipDate || '该日'} 在物流镜像表里 0 条发货记录`
|
||
+ `(表最后更新 ${freshness.sourceLastUpdate || '未知'},数据源本身正常)——`
|
||
+ ' 若当天确有发货,说明上游漏同步了这一天,请到聚水潭核对。');
|
||
} else if (shipped === 0) {
|
||
head.push('本批发货 0 单,无异常可核。');
|
||
} else {
|
||
head.push('本批无转运异常,全部正常。');
|
||
}
|
||
}
|
||
|
||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||
+ (Number(exclusions.voidedPackages) || 0)
|
||
+ (Number(exclusions.nonRealPackages) || 0)
|
||
+ (Number(exclusions.transferPackages) || 0);
|
||
if (excludedCount > 0) {
|
||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发,不计考核)`);
|
||
}
|
||
// 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母
|
||
const interceptCount = Number(exclusions.interceptPackages) || 0;
|
||
if (interceptCount > 0) {
|
||
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
|
||
}
|
||
|
||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
|
||
const detailAll = pickDetailRows(rows, maxDetailRows).map((row, index) => buildDetailLine(row, index + 1));
|
||
const wantCount = 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) };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调度:每分钟问一次 `resolveScheduledPushDecision`。
|
||
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
|
||
* 之所以不是「HH:mm 严格相等」:那样只给一次机会,09:10 撞上业务库连不上就整天不推(11-11 实况)。
|
||
*/
|
||
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();
|
||
let windowClosedLoggedDate = '';
|
||
cron.schedule(expression, () => {
|
||
if (tickRunning) return;
|
||
tickRunning = true;
|
||
(async () => {
|
||
try {
|
||
const now = new Date();
|
||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
|
||
if (!config.enabled) return;
|
||
const state = await loadWarehouseLogisticsExceptionNotifyState();
|
||
const decision = resolveScheduledPushDecision({ now, sendTime: config.sendTime, state });
|
||
if (!decision.run) {
|
||
// 窗口刚过、当天又一直没推成功 → 打一条,明确「今天不会自己来了」,别让人一直等
|
||
if (decision.reason === 'window_closed' && windowClosedLoggedDate !== decision.pushDate) {
|
||
windowClosedLoggedDate = decision.pushDate;
|
||
console.error(
|
||
`[WleNotify] 今日推送窗口已关闭(${config.sendTime} 起 ${RETRY_WINDOW_MINUTES} 分钟内重试,`
|
||
+ '始终未成功),今天不再自动重试,请人工处理'
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 先落「本轮尝试」:失败后下一分钟的 tick 才能按间隔节流。
|
||
// 成功时 push 内部会覆盖 lastSentDate(去重键),此后本函数直接走 already_sent_today。
|
||
await saveWarehouseLogisticsExceptionNotifyState({
|
||
...state,
|
||
attemptDate: decision.pushDate,
|
||
attemptCount: decision.attempt,
|
||
lastAttemptAt: now.toISOString(),
|
||
});
|
||
await runWarehouseLogisticsExceptionNotifyJob(
|
||
decision.reason === 'retry' ? 'daily_retry' : 'daily_schedule',
|
||
{ now }
|
||
);
|
||
} catch (error) {
|
||
console.error('[WleNotify] 定时检查失败:', error.message || error);
|
||
} finally {
|
||
tickRunning = false;
|
||
}
|
||
})();
|
||
}, { timezone: TIMEZONE });
|
||
|
||
console.log(
|
||
`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送,`
|
||
+ `失败后每 ${RETRY_INTERVAL_MINUTES} 分钟重试、最多 ${RETRY_WINDOW_MINUTES} 分钟内 (${TIMEZONE})`
|
||
);
|
||
}
|
||
|
||
module.exports = {
|
||
DEFAULT_RETRY_INTERVAL_MINUTES,
|
||
DEFAULT_RETRY_WINDOW_MINUTES,
|
||
DEFAULT_TICK_CRON,
|
||
SAFE_CONTENT_BYTES,
|
||
TIMEZONE,
|
||
buildCenterLines,
|
||
buildDetailLine,
|
||
buildFreshnessWarning,
|
||
buildNotifyText,
|
||
buildTypeLine,
|
||
chinaDateKey,
|
||
chinaHourMinute,
|
||
groupRowsByWarehouse,
|
||
minuteOfDay,
|
||
pickDetailRows,
|
||
cleanCell,
|
||
pushWarehouseLogisticsExceptionNotify,
|
||
resolveScheduledPushDecision,
|
||
runWarehouseLogisticsExceptionNotifyJob,
|
||
startWarehouseLogisticsExceptionNotifyScheduler,
|
||
};
|