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