'use strict'; /** * 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群 * ──────────────────────────────────────────────────────────────────────────── * 业务口径(2026-10-22 定稿): * ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群; * **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。 * ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。 * 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。 * N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。 * 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。 * ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。 * ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。 * ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置, * 关键词一般是「异常」,缺了会被拒(errcode 310000)。 * ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重, * 服务重启不会重复轰炸;手动「立即推送」才强制重发。 * * 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。 */ const cron = require('node-cron'); const { getLogisticsExceptionExportRows } = require('../services/warehouseLogisticsExceptionService'); const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender'); const { DEFAULT_MAX_DETAIL_ROWS, DEFAULT_PAGE_URL, getWarehouseLogisticsExceptionNotifyRuntimeConfig, isWarehouseLogisticsExceptionNotifyReady, loadWarehouseLogisticsExceptionNotifyState, saveWarehouseLogisticsExceptionNotifyState, } = require('../services/warehouseLogisticsExceptionNotifySettings'); const TIMEZONE = 'Asia/Shanghai'; // 每分钟醒一次,比对「现在是不是配置的推送时刻」—— 这样业务在页面上改时间不用重启后端 const DEFAULT_TICK_CRON = '* * * * *'; // 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败 const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300; let tickRunning = false; function pad2(value) { return String(value).padStart(2, '0'); } /** 北京时间的 YYYY-MM-DD */ function chinaDateKey(date = new Date()) { const base = date instanceof Date ? date : new Date(date); if (!Number.isFinite(base.getTime())) return ''; return new Intl.DateTimeFormat('en-CA', { timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit', }).format(base); } /** 北京时间的 HH:mm —— 用来跟配置里的 sendTime 比对 */ function chinaHourMinute(date = new Date()) { const base = date instanceof Date ? date : new Date(date); if (!Number.isFinite(base.getTime())) return ''; return new Intl.DateTimeFormat('en-GB', { timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', hour12: false, }).format(base); } /** * 字段清洗:换行/连续空白压成单空格。 * ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。 */ function cleanCell(value, fallback = '') { const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim(); return text || fallback; } /** * 明细一行:仓|快递单号|快递|异常类型 * ⚠️ 业务明确要求明细**不带发货时间**(10-22 反馈),别再加 sendTime 回去 —— * 仓库看单子只需知道「哪个单、什么异常」,时间类信息一律到页面上看。 * ⚠️ 第二段用**快递单号**(`trackingNo` = 上游 `logistic_id`),不是内部单号 `oId`(10-22 二次要求)。 * 「未揽收 / 无物流单」这类包裹本来就没有快递单号 → 退回内部单号,否则群里这条无法定位。 * ⚠️ 末尾的「待处理小时数」(`pendingHours`)也已按 10-22 三次要求去掉,别再加回来。 */ function buildDetailLine(row, index) { const parts = [ cleanCell(row?.warehouseName, '未知仓'), cleanCell(row?.trackingNo || row?.oId, '无单号'), cleanCell(row?.carrier, '未知快递'), cleanCell(row?.exceptionLabel, '异常'), ]; return `${index}. ${parts.join('|')}`; } /** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/ function buildCenterLines(data = {}) { const rows = Array.isArray(data.rows) ? data.rows : []; const packagesByCenter = rows.reduce((acc, row) => { const key = String(row?.centerKey || ''); if (!key) return acc; acc[key] = (acc[key] || 0) + 1; return acc; }, {}); return (data.centers || []) .map((center) => ({ name: cleanCell(center.name || center.key, '未知仓'), exceptionPackages: packagesByCenter[center.key] || 0, shippedOrders: Number(center.shippedOrders) || 0, })) .filter((item) => item.exceptionPackages > 0) .sort((a, b) => b.exceptionPackages - a.exceptionPackages) .slice(0, 12) .map((item, index) => `${index + 1}. ${item.name} ${item.exceptionPackages} 个(发货 ${item.shippedOrders} 单)`); } function buildTypeLine(data = {}) { const summary = data.summary || {}; const entries = Object.entries(summary.byType || {}); if (!entries.length) return ''; return entries .map(([type, count]) => { const meta = (data.types || []).find((item) => item.key === type); return { label: meta?.label || type, count: Number(count) || 0 }; }) .sort((a, b) => b.count - a.count) .map((item) => `${item.label} ${item.count}`) .join(' · '); } function buildPageLink(pageUrl, verifyDate) { const base = String(pageUrl || DEFAULT_PAGE_URL); if (!verifyDate) return base; const glue = base.includes('?') ? '&' : '?'; return `${base}${glue}date=${encodeURIComponent(verifyDate)}`; } /** * 组装群消息正文(纯函数,便于单测)。 * 结构:抬头 → 数字 → 分类型 → 按仓 → 明细前 N 条 → 页面链接 * 超长处理:从明细末尾往前砍,砍到能发为止(绝不让钉钉 400 把整条消息吞掉)。 * * @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}} */ function buildNotifyText(data = {}, options = {}) { const batch = data.batch || {}; const summary = data.summary || {}; const rows = Array.isArray(data.rows) ? data.rows : []; const exclusions = data.exclusions || {}; const maxDetailRows = Number.isFinite(Number(options.maxDetailRows)) ? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows)))) : DEFAULT_MAX_DETAIL_ROWS; const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, batch.verifyDate); const totalPackages = Number(summary.exceptionPackages) || rows.length; const shipped = Number(summary.shippedOrders) || 0; const ratio = Number.isFinite(Number(summary.exceptionRate)) ? `${Number(summary.exceptionRate)}%` : '—'; const head = [ `【仓储物流转运异常】${cleanCell(batch.label, '本批核验')}`, `已发货 ${shipped} 单 / ${Number(summary.packageCount) || 0} 个包裹,异常 ${totalPackages} 个包裹(${ratio})`, ]; const typeLine = buildTypeLine(data); if (typeLine) head.push(`类型:${typeLine}`); if (totalPackages === 0) { head.push('本批无转运异常,全部正常。'); } const excludedCount = (Number(exclusions.carrierPackages) || 0) + (Number(exclusions.voidedPackages) || 0) + (Number(exclusions.nonRealPackages) || 0); if (excludedCount > 0) { head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`); } const centerLines = totalPackages > 0 ? buildCenterLines(data) : []; const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1)); const wantCount = Math.min(maxDetailRows, detailAll.length); // 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出) const buildTail = (listed) => { const lines = []; if (totalPackages > 0) { if (listed === 0) { lines.push(''); lines.push(`明细共 ${totalPackages} 条,消息里不放,请点下面链接查看。`); } else if (totalPackages > listed) { lines.push(`…其余 ${totalPackages - listed} 条见页面。`); } lines.push(`查看全部 / 导出 Excel:${pageUrl}`); } return lines; }; const assemble = (listed, withCenters, extraNote = '') => { const blocks = [...head]; if (withCenters && centerLines.length) { blocks.push(''); blocks.push('按仓(异常包裹):'); blocks.push(...centerLines); } if (listed > 0) { blocks.push(''); blocks.push(`明细前 ${listed} 条:`); blocks.push(...detailAll.slice(0, listed)); } const tail = buildTail(listed); if (tail.length) blocks.push(...tail); if (extraNote) blocks.push(extraNote); const text = blocks.join('\n'); return { text, bytes: Buffer.byteLength(text, 'utf8') }; }; // 先按期望条数拼;超长就从明细末尾往前砍(先砍明细,再砍分仓块) // ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」—— // 后者是正常行为(listedCount < rowCount),混在一起会让页面误报「超长精简」。 let listed = wantCount; let withCenters = centerLines.length > 0; let result = assemble(listed, withCenters); let truncated = false; while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) { listed -= 1; truncated = true; result = assemble(listed, withCenters); } if (result.bytes > SAFE_CONTENT_BYTES && withCenters) { withCenters = false; result = assemble(listed, withCenters); } if (result.bytes > SAFE_CONTENT_BYTES) { // 极端情况(仓名/链接特别长):硬截断,至少保证消息能发出去 const chars = Array.from(result.text); let bytes = 0; let cut = ''; for (const char of chars) { const size = Buffer.byteLength(char, 'utf8'); if (bytes + size > SAFE_CONTENT_BYTES) break; bytes += size; cut += char; } result = { text: cut, bytes }; truncated = true; } return { text: result.text, rowCount: totalPackages, listedCount: listed, bytes: result.bytes, truncated, }; } /** * 执行一次推送。参数: * now 指定时间(测试用) * date 指定核验日 'YYYY-MM-DD' * force 忽略「今天已经推过」的限制(页面「立即推送」用) * dryRun 只组装消息不发送(预览 / 单测用) * probe 只发一条通道测试文本(不查库、不依赖业务数据) */ async function pushWarehouseLogisticsExceptionNotify(options = {}) { const now = options.now === undefined ? new Date() : new Date(options.now); const pushDate = chinaDateKey(now); const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true }); // 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅) if (options.probe === true) { if (!isWarehouseLogisticsExceptionNotifyReady(config)) { return { sent: false, reason: 'not_configured', missingFields: config.missingFields }; } const delivery = await sendTextToWebhook({ webhookUrl: config.webhookUrl, secret: config.webhookSecret, content: '【仓储物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。', }); return { sent: true, probe: true, delivery }; } const state = await loadWarehouseLogisticsExceptionNotifyState(); if (state?.lastSentDate === pushDate && options.force !== true) { return { sent: false, reason: 'already_sent_today', pushDate, lastSentAt: state.lastSentAt || null }; } if (!config.enabled && options.force !== true) { return { sent: false, reason: 'disabled' }; } const dryRun = options.dryRun === true; if (!dryRun && !isWarehouseLogisticsExceptionNotifyReady(config)) { return { sent: false, reason: 'not_configured', missingFields: config.missingFields }; } const data = await getLogisticsExceptionExportRows({ date: options.date }); if (data?.notStarted) { // 还没到核验时刻(例如把推送时间设在 09:00 之前)→ 不推空数据,等下一轮或人工 return { sent: false, reason: 'not_started', batch: data.batch || null }; } const built = buildNotifyText(data, { maxDetailRows: config.maxDetailRows, pageUrl: config.pageUrl, }); if (dryRun) { return { sent: false, reason: 'dry_run', text: built.text, rowCount: built.rowCount, listedCount: built.listedCount, bytes: built.bytes, truncated: built.truncated, batch: data.batch || null, summary: data.summary || null, }; } const delivery = await sendTextToWebhook({ webhookUrl: config.webhookUrl, secret: config.webhookSecret, content: built.text, atAll: config.atAll === true, }); await saveWarehouseLogisticsExceptionNotifyState({ lastSentDate: pushDate, lastSentAt: new Date().toISOString(), lastStatus: 'sent', lastError: '', rowCount: built.rowCount, listedCount: built.listedCount, bytes: built.bytes, truncated: built.truncated, verifyDate: data.batch?.verifyDate || '', }); return { sent: true, rowCount: built.rowCount, listedCount: built.listedCount, bytes: built.bytes, truncated: built.truncated, text: built.text, batch: data.batch || null, summary: data.summary || null, delivery, }; } async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', options = {}) { try { const result = await pushWarehouseLogisticsExceptionNotify(options); if (result.sent) { console.log(`[WleNotify] ${source} 已推送(${result.rowCount} 条异常,列出 ${result.listedCount} 条,${result.bytes} 字节)`); } else { console.log(`[WleNotify] ${source} 跳过: ${result.reason}`); } return result; } catch (error) { console.error(`[WleNotify] ${source} 执行失败:`, error.message || error); try { await saveWarehouseLogisticsExceptionNotifyState({ ...(await loadWarehouseLogisticsExceptionNotifyState()), lastError: String(error.message || error).slice(0, 500), lastFailedAt: new Date().toISOString(), }); } catch (_ignored) { /* 状态写失败不影响主流程 */ } return { sent: false, reason: 'error', error: error.message || String(error) }; } } /** * 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。 * 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。 */ function startWarehouseLogisticsExceptionNotifyScheduler() { const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true'; if (disabled) { console.log('[Scheduler] 物流转运异常钉钉推送已禁用'); return; } const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim(); cron.schedule(expression, () => { if (tickRunning) return; tickRunning = true; (async () => { try { const now = new Date(); const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig(); if (!config.enabled) return; if (chinaHourMinute(now) !== config.sendTime) return; await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now }); } catch (error) { console.error('[WleNotify] 定时检查失败:', error.message || error); } finally { tickRunning = false; } })(); }, { timezone: TIMEZONE }); console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`); } module.exports = { DEFAULT_TICK_CRON, SAFE_CONTENT_BYTES, TIMEZONE, buildCenterLines, buildDetailLine, buildNotifyText, buildTypeLine, chinaDateKey, chinaHourMinute, cleanCell, pushWarehouseLogisticsExceptionNotify, runWarehouseLogisticsExceptionNotifyJob, startWarehouseLogisticsExceptionNotifyScheduler, };