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