forked from lunanting/cangku
业务 2026-10-28 下午拿 9837824 指出:页面「创建时间」显示 10-19 23:44, 而该单实际是 10-28 09:58:42 才审核。逐字段核对确认起算点口径错了 —— 上午那版按 jst_Orders.created 算,等于把客服审核积压算到仓库头上。 - 起算点 = 状态表 last_oa_confirm_time(业务选「最后审核时间」); 取不到审核时间的单回退 created,页面 / 导出 / 钉钉该列打 * - 已发判定补一条兜底:订单表 send_date 空、但状态表 send_time 有值的按已发剔除 (实测 144 单里 6 单属此类,且有揽收轨迹) - 明细与导出列改为「审核时间 / 下单时间 / 发货时间」并列,去掉旧「创建时间」列 - 钉钉明细第五段由「创建」改回「审核」;抬头「核验 XX 前创建」改「前审核」 - 回归测试同步改断言;stub 增加审核时间查询分支(默认空 = 全量回退 created) 实测(10-28 13:00 轮):599 → 144(按审核时间分轮)→ 138(剔除漏回写已发单)。 599 单里 376 单是当天 08:10~10:30 才批量审核的积压单,491 单其实已有运单号。 验收:后端 check 491/493(2 个失败为既有 Windows 权限 / TTL 用例,改动前同样失败); 前端 eslint 通过、build 23.92s 通过;本地真渲染 138 单、表头含「审核时间 / 下单时间」、 回退单带 *、无 pageerror。
694 lines
27 KiB
JavaScript
694 lines
27 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 仓储中心发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* 业务口径(2026-10-27 原话):
|
||
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
|
||
* 18:00 核验的订单,核验完成后也通知。」
|
||
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
|
||
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
|
||
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
|
||
* ⚠️ 2026-10-28 起数据源换成订单两表(jst_Orders / jst_qmcust_orderlist),
|
||
* 起算点是**审核时间** `last_oa_confirm_time`(不是订单创建时间);
|
||
* 取不到审核时间的单回退创建时间,正文里在该时间后加 `*` 提示。
|
||
* 推送正文写「审核 HH:mm」(此前一版误写「创建」,已纠正)。
|
||
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
|
||
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
|
||
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
|
||
*
|
||
* 消息内容:
|
||
* 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等)
|
||
* + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。
|
||
* 明细一行 = 仓|内部单号|线上单号|快递|创建 HH:mm|已等 X 小时(业务对账用)。
|
||
* (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明)
|
||
* 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。
|
||
*
|
||
* 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。
|
||
* 推送成功才写状态;发送失败绝不写,否则业务配好通道后反而不推了。
|
||
*
|
||
* 时区:生产容器是 UTC,一切「今天 / 现在几点」都用 Asia/Shanghai 判。
|
||
*/
|
||
|
||
const cron = require('node-cron');
|
||
const {
|
||
getShipTimeliness,
|
||
DEFAULT_DETAIL_LIMIT,
|
||
EXCLUDED_CARRIERS,
|
||
} = require('../services/warehouseShipTimelinessService');
|
||
const { ROUNDS, beijingNow, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
|
||
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
||
const {
|
||
DEFAULT_MAX_DETAIL_ROWS,
|
||
DEFAULT_PAGE_URL,
|
||
DEFAULT_SEND_TIME_AFTERNOON,
|
||
DEFAULT_SEND_TIME_NOON,
|
||
buildPushSlot,
|
||
getShipTimelinessNotifyRuntimeConfig,
|
||
isShipTimelinessNotifyReady,
|
||
loadShipTimelinessNotifyState,
|
||
saveShipTimelinessNotifyState,
|
||
trimSentMap,
|
||
} = require('../services/warehouseShipTimelinessNotifySettings');
|
||
|
||
const TIMEZONE = 'Asia/Shanghai';
|
||
const DEFAULT_TICK_CRON = '* * * * *';
|
||
// 钉钉 text 上限 20000 字节;留 300 字节给尾巴,避免临界失败
|
||
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
||
// 启动补推窗口:后端在 13:05 那一刻没跑(重启 / 部署),30 分钟内起来还补一次
|
||
const CATCH_UP_WINDOW_MINUTES = 30;
|
||
|
||
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 —— 用来跟配置里的推送时刻比对 */
|
||
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 换算成「当天第几分钟」,方便做 +/- 比较 */
|
||
function minutesOfDay(date = new Date()) {
|
||
const text = chinaHourMinute(date);
|
||
if (!text) return null;
|
||
const [hour, minute] = text.split(':').map((part) => Number(part));
|
||
return hour * 60 + minute;
|
||
}
|
||
|
||
function parseSendTime(text) {
|
||
const [hour, minute] = String(text || '').split(':').map((part) => Number(part));
|
||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||
return hour * 60 + minute;
|
||
}
|
||
|
||
/** 字段清洗:换行 / 连续空白压成单空格(避免一条明细被劈成两行) */
|
||
function cleanCell(value, fallback = '') {
|
||
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||
return text || fallback;
|
||
}
|
||
|
||
/** 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」,与页面同一套写法 */
|
||
function formatOverdue(minutes) {
|
||
const m = Number(minutes);
|
||
if (!Number.isFinite(m) || m <= 0) return '—';
|
||
if (m < 60) return `${m} 分钟`;
|
||
const h = Math.floor(m / 60);
|
||
const rest = m % 60;
|
||
if (h < 24) return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
|
||
const d = Math.floor(h / 24);
|
||
const hr = h % 24;
|
||
return hr ? `${d} 天 ${hr} 小时` : `${d} 天`;
|
||
}
|
||
|
||
/**
|
||
* 起算时间(= 审核时间 `last_oa_confirm_time`)的展示:
|
||
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
||
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
|
||
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
|
||
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
|
||
*
|
||
* ⚠️ 传进来的 `auditTime` 是服务层的 benchmarkAt:审核时间,取不到时回退订单创建时间
|
||
* (此时 row.benchmarkSource === 'created',调用方会给时间加 `*`)。
|
||
*/
|
||
function auditWhen(auditTime, roundDate) {
|
||
const text = cleanCell(auditTime);
|
||
if (!text) return '';
|
||
const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||
if (!hit) {
|
||
const clock = text.match(/(\d{2}:\d{2})/);
|
||
return clock ? clock[1] : text;
|
||
}
|
||
const [, , month, day, hour, minute] = hit;
|
||
const sameDay = Boolean(roundDate) && String(roundDate).slice(5, 10) === `${month}-${day}`;
|
||
return sameDay ? `${hour}:${minute}` : `${month}-${day} ${hour}:${minute}`;
|
||
}
|
||
|
||
/**
|
||
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
|
||
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
||
* 两者业务都要,别只留一个。
|
||
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
||
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
||
* 不用再回页面一单单点开确认。
|
||
* 🔥 2026-10-28 第五段是「审核」时间(起算点),不是订单创建时间;
|
||
* 审核时间缺失、回退订单创建时间的单,时间后面带 `*`。
|
||
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
|
||
*/
|
||
function buildDetailLine(row, index, roundDate) {
|
||
const when = auditWhen(row?.auditTime, roundDate);
|
||
const auditText = when
|
||
? `审核 ${when}${row?.benchmarkSource === 'created' ? '*' : ''}`
|
||
: '审核 —';
|
||
const parts = [
|
||
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
||
cleanCell(row?.oId, '无单号'),
|
||
cleanCell(row?.soId, '—'),
|
||
cleanCell(row?.logisticCompany, '—'),
|
||
auditText,
|
||
`已等 ${formatOverdue(row?.overdueMinutes)}`,
|
||
];
|
||
return `${index}. ${parts.join('|')}`;
|
||
}
|
||
|
||
/** 按仓分桶(用主仓名),组间按超时单数降序,组内保持服务层原顺序(已按时长降序) */
|
||
function bucketRowsByCenter(rows) {
|
||
const buckets = new Map();
|
||
const firstSeen = new Map();
|
||
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
|
||
const key = cleanCell(row?.centerName || 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);
|
||
}
|
||
|
||
/**
|
||
* 按仓分配明细额度,返回已按仓聚集的行。
|
||
* 同 WLE 的理由:某仓超时特别多时会一口气吃满额度,其它仓一条都看不到。
|
||
* ① 仓数 ≥ 额度 → 每仓 1 条;
|
||
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法)。
|
||
*/
|
||
function pickDetailRows(rows, limit) {
|
||
const groups = bucketRowsByCenter(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));
|
||
}
|
||
|
||
/** 按仓汇总块:只列超时 > 0 的仓,降序,最多 12 行 */
|
||
function buildCenterLines(data = {}) {
|
||
return (data.centers || [])
|
||
.map((center) => ({
|
||
name: cleanCell(center.name || center.key, '未知仓'),
|
||
overdue: Number(center.overdueUnshipped) || 0,
|
||
oldestDays: Number(center.oldestDays) || 0,
|
||
}))
|
||
.filter((item) => item.overdue > 0)
|
||
.sort((a, b) => b.overdue - a.overdue)
|
||
.slice(0, 12)
|
||
.map((item, index) => (
|
||
`${index + 1}. ${item.name} ${item.overdue} 单`
|
||
+ (item.oldestDays > 0 ? `(最久 ${item.oldestDays} 天)` : '')
|
||
));
|
||
}
|
||
|
||
function buildPageLink(pageUrl, roundKey) {
|
||
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
||
if (!roundKey) return base;
|
||
const glue = base.includes('?') ? '&' : '?';
|
||
return `${base}${glue}round=${encodeURIComponent(roundKey)}`;
|
||
}
|
||
|
||
/**
|
||
* 组装群消息正文(纯函数,便于单测)。
|
||
*
|
||
* @param {object} data getShipTimeliness 的返回
|
||
* @param {object} options maxDetailRows / pageUrl / roundKey
|
||
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
|
||
*/
|
||
function buildNotifyText(data = {}, options = {}) {
|
||
const summary = data.summary || {};
|
||
const round = data.round || {};
|
||
const window = data.window || {};
|
||
const rows = Array.isArray(data.orders) ? data.orders : [];
|
||
const freshness = data.freshness || {};
|
||
|
||
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
|
||
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
|
||
: DEFAULT_MAX_DETAIL_ROWS;
|
||
const roundKey = options.roundKey || round.key || '';
|
||
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, roundKey);
|
||
|
||
const overdue = Number(summary.overdueUnshipped) || 0;
|
||
const centerCount = Number(summary.overdueCenterCount) || 0;
|
||
const oldestDays = Number(summary.oldestDays) || 0;
|
||
const heldCount = Number(summary.heldCount) || 0;
|
||
const roundLabel = cleanCell(round.label, '本轮核验');
|
||
const windowEnd = String(window.end || '').slice(11, 16);
|
||
|
||
const head = [
|
||
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前审核` : ''}`,
|
||
`超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓`
|
||
+ (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''),
|
||
];
|
||
|
||
if (overdue === 0) {
|
||
head.push('本轮没有超时未发货的订单,全部按时发出。');
|
||
}
|
||
if (heldCount > 0) {
|
||
head.push(`另有 ${heldCount} 单不计考核(待审核 / 订单异常)`);
|
||
}
|
||
const carrierSkipped = Number(summary.carrierSkipped) || 0;
|
||
if (carrierSkipped > 0) {
|
||
// 快递清单由服务层给(`excludedCarriers`),别在这里写死 ——
|
||
// 2026-10-27 加「武进安能」时就吃过一次:写死的文案忘了改,群里口径和页面不一致。
|
||
const carriers = (Array.isArray(data.excludedCarriers) ? data.excludedCarriers : EXCLUDED_CARRIERS)
|
||
.filter(Boolean);
|
||
head.push(`已剔除 ${carrierSkipped} 单不考核快递${carriers.length ? `(${carriers.join(' / ')})` : ''}`);
|
||
}
|
||
// 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单
|
||
if (freshness.coversDeadline === false) {
|
||
head.push(
|
||
`⚠️ 数据快照只到 ${String(freshness.stateMaxQuery || '').slice(11, 16) || '未知时间'},`
|
||
+ `尚未覆盖本轮核验时刻 ${String(data.deadline || '').slice(11, 16)},超时单量偏大,请以聚水潭实时状态为准。`
|
||
);
|
||
}
|
||
|
||
const centerLines = overdue > 0 ? buildCenterLines(data) : [];
|
||
const roundDate = String(data.date || round.date || '').slice(0, 10);
|
||
const detailAll = pickDetailRows(rows, maxDetailRows)
|
||
.map((row, index) => buildDetailLine(row, index + 1, roundDate));
|
||
const wantCount = detailAll.length;
|
||
|
||
const buildTail = (listed) => {
|
||
const lines = [];
|
||
if (overdue > 0) {
|
||
if (listed === 0) {
|
||
lines.push('');
|
||
lines.push(`明细共 ${overdue} 条,消息里不放,请点下面链接查看。`);
|
||
} else if (overdue > listed) {
|
||
lines.push(`…其余 ${overdue - listed} 条见页面。`);
|
||
}
|
||
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
|
||
}
|
||
return lines;
|
||
};
|
||
|
||
const assemble = (listed, withCenters) => {
|
||
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);
|
||
const text = blocks.join('\n');
|
||
return { text, bytes: Buffer.byteLength(text, 'utf8') };
|
||
};
|
||
|
||
// 超长就从明细末尾往前砍(先砍明细,再砍分仓块)
|
||
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」
|
||
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: overdue,
|
||
listedCount: listed,
|
||
bytes: result.bytes,
|
||
truncated,
|
||
};
|
||
}
|
||
|
||
/** 取某一轮次今天的元信息(deadline / windowEnd / label) */
|
||
function resolveRoundMeta(roundKey, now = new Date()) {
|
||
const key = String(roundKey || '').toUpperCase();
|
||
const target = ROUNDS.find((round) => round.key === key);
|
||
if (!target) return null;
|
||
const described = describeRounds(now).find((round) => round.key === key);
|
||
if (!described) return null;
|
||
return {
|
||
key: target.key,
|
||
label: described.label || target.label,
|
||
date: described.date,
|
||
verifyHour: target.verifyHour,
|
||
deadline: described.deadline,
|
||
windowEnd: described.windowEnd,
|
||
};
|
||
}
|
||
|
||
/** 某个轮次今天该在几点推(读配置) */
|
||
function sendTimeForRound(config, roundKey) {
|
||
const key = String(roundKey || '').toUpperCase();
|
||
if (key === 'AFTERNOON') return config.afternoonSendTime || DEFAULT_SEND_TIME_AFTERNOON;
|
||
return config.noonSendTime || DEFAULT_SEND_TIME_NOON;
|
||
}
|
||
|
||
/**
|
||
* 执行一次推送。参数:
|
||
* round 'NOON' | 'AFTERNOON'(不传 = 按当前时刻自动判定)
|
||
* now 指定时间(测试用)
|
||
* force 忽略「今天这轮已推过」(页面「立即推送」用)
|
||
* dryRun 只组装消息不发送(预览 / 单测用)
|
||
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
||
*/
|
||
async function pushShipTimelinessNotify(options = {}) {
|
||
// 🔥 两个「现在」不能混用(生产容器 /etc/timezone = Etc/UTC):
|
||
// · rawNow —— 真实时刻。凡是走 Intl(Asia/Shanghai) 或 toISOString 的地方用它;
|
||
// · bjNow —— 平移成「本地 getter 读出来正好是北京时间」的 Date。
|
||
// registry.describeRounds / resolveRoundMeta / getShipTimeliness 内部都用
|
||
// `getDate()/getHours()` 直接读,必须喂 bjNow —— 否则北京 00:00~08:00 会取到前一天
|
||
// (2026-10-21 生产踩过:13:00 轮算出 3895 单,实际只有 6 单)。
|
||
const rawNow = options.now === undefined ? new Date() : new Date(options.now);
|
||
const bjNow = beijingNow(rawNow);
|
||
const pushDate = chinaDateKey(rawNow);
|
||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||
|
||
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
||
if (options.probe === true) {
|
||
if (!isShipTimelinessNotifyReady(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 };
|
||
}
|
||
|
||
// 轮次:显式指定优先;否则按当前时刻自动判定(13:00 前 = 没有轮次)
|
||
const explicitRound = String(options.round || '').toUpperCase();
|
||
let roundKey = explicitRound;
|
||
if (!roundKey) {
|
||
const minutes = minutesOfDay(rawNow);
|
||
let active = null;
|
||
for (const round of ROUNDS) {
|
||
if (minutes >= round.verifyHour * 60) active = round;
|
||
}
|
||
if (!active) return { sent: false, reason: 'not_started' };
|
||
roundKey = active.key;
|
||
}
|
||
|
||
const meta = resolveRoundMeta(roundKey, bjNow);
|
||
if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey };
|
||
|
||
// 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick)
|
||
const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16));
|
||
if (deadlineMinutes != null && minutesOfDay(rawNow) < deadlineMinutes) {
|
||
return { sent: false, reason: 'before_deadline', round: roundKey, deadline: meta.deadline };
|
||
}
|
||
|
||
const slot = buildPushSlot(pushDate, roundKey);
|
||
const state = await loadShipTimelinessNotifyState();
|
||
const sentMap = (state && typeof state.sent === 'object' && state.sent) ? state.sent : {};
|
||
if (sentMap[slot] && options.force !== true) {
|
||
return {
|
||
sent: false,
|
||
reason: 'already_sent',
|
||
slot,
|
||
lastSentAt: sentMap[slot].sentAt || null,
|
||
round: roundKey,
|
||
};
|
||
}
|
||
if (!config.enabled && options.force !== true) {
|
||
return { sent: false, reason: 'disabled', round: roundKey };
|
||
}
|
||
|
||
const dryRun = options.dryRun === true;
|
||
if (!dryRun && !isShipTimelinessNotifyReady(config)) {
|
||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||
}
|
||
|
||
// 明细条数用默认 500(= 页面同一档),这样与页面共用结果缓存:
|
||
// 推送完业务点进页面几乎是热的,不会为了推一条消息额外扫一次订单视图。
|
||
// summary/centers 都是全量算的,limit 只截断明细,不影响数字。
|
||
const data = await getShipTimeliness({
|
||
round: roundKey,
|
||
limit: DEFAULT_DETAIL_LIMIT,
|
||
now: bjNow,
|
||
});
|
||
if (data?.notStarted) {
|
||
return { sent: false, reason: 'not_started', round: roundKey };
|
||
}
|
||
|
||
const built = buildNotifyText(data, {
|
||
maxDetailRows: config.maxDetailRows,
|
||
pageUrl: config.pageUrl,
|
||
roundKey,
|
||
});
|
||
|
||
if (dryRun) {
|
||
return {
|
||
sent: false,
|
||
reason: 'dry_run',
|
||
round: roundKey,
|
||
roundLabel: data.round?.label || meta.label,
|
||
deadline: data.deadline || meta.deadline,
|
||
text: built.text,
|
||
rowCount: built.rowCount,
|
||
listedCount: built.listedCount,
|
||
bytes: built.bytes,
|
||
truncated: built.truncated,
|
||
summary: data.summary || null,
|
||
freshness: data.freshness || null,
|
||
};
|
||
}
|
||
|
||
const delivery = await sendTextToWebhook({
|
||
webhookUrl: config.webhookUrl,
|
||
secret: config.webhookSecret,
|
||
content: built.text,
|
||
atAll: config.atAll === true,
|
||
});
|
||
|
||
const nextSent = {
|
||
...sentMap,
|
||
[slot]: {
|
||
sentAt: rawNow.toISOString(),
|
||
rowCount: built.rowCount,
|
||
listedCount: built.listedCount,
|
||
bytes: built.bytes,
|
||
deadline: data.deadline || meta.deadline || '',
|
||
roundLabel: data.round?.label || meta.label || '',
|
||
},
|
||
};
|
||
|
||
await saveShipTimelinessNotifyState({
|
||
version: 1,
|
||
sent: trimSentMap(nextSent),
|
||
lastSentAt: rawNow.toISOString(),
|
||
lastStatus: 'sent',
|
||
lastError: '',
|
||
lastRound: roundKey,
|
||
lastRoundLabel: data.round?.label || meta.label || '',
|
||
lastSlot: slot,
|
||
rowCount: built.rowCount,
|
||
listedCount: built.listedCount,
|
||
bytes: built.bytes,
|
||
truncated: built.truncated,
|
||
});
|
||
|
||
return {
|
||
sent: true,
|
||
round: roundKey,
|
||
roundLabel: data.round?.label || meta.label,
|
||
slot,
|
||
rowCount: built.rowCount,
|
||
listedCount: built.listedCount,
|
||
bytes: built.bytes,
|
||
truncated: built.truncated,
|
||
text: built.text,
|
||
summary: data.summary || null,
|
||
delivery,
|
||
};
|
||
}
|
||
|
||
async function runShipTimelinessNotifyJob(source = 'scheduled', options = {}) {
|
||
try {
|
||
const result = await pushShipTimelinessNotify(options);
|
||
if (result.sent) {
|
||
console.log(
|
||
`[WstNotify] ${source} 已推送 ${result.round}(超时 ${result.rowCount} 单,列出 ${result.listedCount} 条,${result.bytes} 字节)`
|
||
);
|
||
} else {
|
||
console.log(`[WstNotify] ${source} 跳过: ${result.reason}${result.round ? ` (${result.round})` : ''}`);
|
||
}
|
||
return result;
|
||
} catch (error) {
|
||
console.error(`[WstNotify] ${source} 执行失败:`, error.message || error);
|
||
try {
|
||
await saveShipTimelinessNotifyState({
|
||
...(await loadShipTimelinessNotifyState()),
|
||
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,两轮都要推)。
|
||
* · 精确命中 HH:mm → 推
|
||
* · 刚启动的补推窗口(sendTime ~ sendTime+30min 内)→ 推(是否重复由调用方按 slot 去重)
|
||
* 之所以不用固定 cron:业务在页面上改时刻后要立即生效,不能等重启。
|
||
*/
|
||
function resolveDueRounds(config, now = new Date(), { catchUp = false } = {}) {
|
||
const minutes = minutesOfDay(now);
|
||
if (minutes == null) return [];
|
||
|
||
const due = [];
|
||
for (const round of ROUNDS) {
|
||
const target = parseSendTime(sendTimeForRound(config, round.key));
|
||
if (target == null) continue;
|
||
if (minutes === target) {
|
||
due.push(round.key);
|
||
continue;
|
||
}
|
||
if (catchUp && minutes > target && minutes <= target + CATCH_UP_WINDOW_MINUTES) {
|
||
due.push(round.key);
|
||
}
|
||
}
|
||
return due;
|
||
}
|
||
|
||
/**
|
||
* 调度:每分钟 tick,比对两个轮次各自的推送时刻。
|
||
* 启动时额外做一次「补推」检查:13:05 那一刻后端没在跑(重启 / 部署)也能补上。
|
||
*/
|
||
function startShipTimelinessNotifyScheduler() {
|
||
const disabled = String(process.env.DISABLE_WST_NOTIFY || '').trim().toLowerCase() === 'true';
|
||
if (disabled) {
|
||
console.log('[Scheduler] 发货时效钉钉推送已禁用');
|
||
return;
|
||
}
|
||
const expression = String(process.env.WST_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
||
|
||
const tick = async ({ catchUp = false } = {}) => {
|
||
if (tickRunning) return;
|
||
tickRunning = true;
|
||
try {
|
||
const now = new Date();
|
||
const config = await getShipTimelinessNotifyRuntimeConfig();
|
||
if (!config.enabled) return;
|
||
const due = resolveDueRounds(config, now, { catchUp });
|
||
if (!due.length) return;
|
||
|
||
const state = catchUp ? await loadShipTimelinessNotifyState() : null;
|
||
for (const roundKey of due) {
|
||
if (catchUp) {
|
||
// 补推只在「今天这轮确实还没推过」时才动手,避免重启就把消息重发一遍
|
||
const slot = buildPushSlot(chinaDateKey(now), roundKey);
|
||
if (state?.sent && state.sent[slot]) continue;
|
||
console.log(`[WstNotify] 启动补推检查命中 ${roundKey}`);
|
||
}
|
||
await runShipTimelinessNotifyJob(catchUp ? 'catch_up' : 'scheduled', { now, round: roundKey });
|
||
}
|
||
} catch (error) {
|
||
console.error('[WstNotify] 定时检查失败:', error.message || error);
|
||
} finally {
|
||
tickRunning = false;
|
||
}
|
||
};
|
||
|
||
cron.schedule(expression, () => { tick(); }, { timezone: TIMEZONE });
|
||
|
||
// 启动后 45s 跑一次补推检查(等业务库/隧道就绪,别和启动预热抢)
|
||
setTimeout(() => { tick({ catchUp: true }); }, 45 * 1000);
|
||
|
||
console.log(
|
||
`[Scheduler] 发货时效钉钉推送已注册: 每分钟检查,13:00 轮→默认 13:05、18:00 轮→默认 18:05 (${TIMEZONE})`
|
||
);
|
||
}
|
||
|
||
module.exports = {
|
||
CATCH_UP_WINDOW_MINUTES,
|
||
DEFAULT_TICK_CRON,
|
||
SAFE_CONTENT_BYTES,
|
||
TIMEZONE,
|
||
auditWhen,
|
||
buildCenterLines,
|
||
buildDetailLine,
|
||
buildNotifyText,
|
||
buildPageLink,
|
||
bucketRowsByCenter,
|
||
chinaDateKey,
|
||
chinaHourMinute,
|
||
cleanCell,
|
||
formatOverdue,
|
||
minutesOfDay,
|
||
parseSendTime,
|
||
pickDetailRows,
|
||
pushShipTimelinessNotify,
|
||
resolveDueRounds,
|
||
resolveRoundMeta,
|
||
runShipTimelinessNotifyJob,
|
||
sendTimeForRound,
|
||
startShipTimelinessNotifyScheduler,
|
||
};
|