From f766549b1ab54500bb872d00137daebec7b876e9 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Tue, 27 Oct 2026 15:43:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(wst):=20=E5=8F=91=E8=B4=A7=E6=97=B6?= =?UTF-8?q?=E6=95=88=E4=B8=A4=E8=BD=AE=E6=A0=B8=E9=AA=8C=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=90=8E=E5=90=84=E6=8E=A8=E4=B8=80=E6=AC=A1=E9=92=89=E9=92=89?= =?UTF-8?q?=E7=BE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务原话(2026-10-27):「发货时效也需要钉钉通知:13 点核验 7 点前的超时单, 核验完成后通知;18:00 核验的订单,核验完成后也通知。」 ## 做了什么 - 新增 `warehouseShipTimelinessNotifySettings`:配置与状态存取,两个轮次各一个推送时刻 (NOON 默认 13:05 / AFTERNOON 默认 18:05),加签密钥走 resilientSecretStore。 - 新增 `warehouseShipTimelinessNotifyJob`:正文组装 + 每分钟 tick + 启动后 30 分钟补推窗口。 - 路由:`GET/PUT /api/warehouse-ship-timeliness/notify/settings`、 `GET .../notify/state`、`POST .../notify/run`(dryRun / probe / force)。 - `index.js` 注册 `startShipTimelinessNotifyScheduler()`。 - 前端:页面右上「钉钉通知」→ 弹窗(开关、两个时刻、webhook、加签密钥、明细条数、 @所有人、页面链接、受控预览块、上次推送)。顺带把 held 明细弹窗的 `destroyOnClose` 改成 `destroyOnHidden`(antd 5.25 已弃用)。 ## 口径要点 - **一天两推,各绑一个轮次**:13:00 轮核验「今天 07:00 前审核」、18:00 轮核验 「截止 16:00 前审核」(都含历史未发)。两轮互不影响,两个时刻配成同一个 HH:mm 时两轮都推。 - **默认核验点 + 5 分钟**,不是整点:状态镜像 `query_time` 要略晚于核验时刻, 整点推容易 `coversDeadline=false`(数据没覆盖核验点、超时数偏大)。 这种时候消息里会带一行提示,仍照发(业务要的是「核验完就通知」)。 - **去重键 `<北京日期>|<轮次键>`**,一天两轮各自独立;推送成功才写状态,失败不写。 - **0 单超时也推**(群里看到「本轮 0 单」= 任务没挂)。 - **明细一行 = 仓|内部单号|线上单号|审核 时间|已等 X 小时**; 历史单的审核时间带 MM-DD —— 13:00 轮含历史未发单,只写「审核 16:13」会被读成今天。 - 明细按仓聚集 + 每仓保底 1 条,钉钉 text 超长时从末尾砍。 ## 验证 - `backend/test/warehouseShipTimelinessNotify.test.js` 新增 17 条,全过。 - 后端 `check-syntax` 通过;`node --test test/*.test.js` 491 条 / 489 过 / 2 失败, 失败两条是既有环境问题(`cloudDatabaseConfigCache` 负缓存用例依赖数据库不可达、 `resilientSecretStore` 的 0600 权限断言在 Windows 恒为 0o666),与本改动无关。 - 前端 `eslint src/pages/WarehouseShipTimeliness src/api/warehouseShipTimeliness.js` 0 错; `vite build` 通过(含 `verify-router-lazy`)。 - 本地真渲染:页面右上出现「钉钉通知」,弹窗完整渲染、点「预览正文」出正文块,无 pageerror (验收时把 `/notify/**` 全部 mock —— 本地系统库与生产是同一个,避免写坏生产配置)。 - 本地实数据预览(13:00 轮,冷查 46s):超时 15 单 / 4 个仓 / 不计考核 672 / 剔除 1; 同一时刻请求 18:00 轮返回 `before_deadline`(未到 18:00 不推半成品)。 ## 风险与回退 - 默认关闭(`enabled=false`)且未配 webhook 时一切接口只返回 `not_configured`,不会发消息。 - 想彻底停掉:`DISABLE_WST_NOTIFY=true` 或把推送开关关掉;两者都不影响页面本身。 - 回退:删掉 `startShipTimelinessNotifyScheduler()` 一行即可停调度,新增文件无副作用。 --- backend/src/api/warehouseShipTimeliness.js | 88 +++ backend/src/index.js | 4 + .../jobs/warehouseShipTimelinessNotifyJob.js | 659 ++++++++++++++++++ .../warehouseShipTimelinessNotifySettings.js | 373 ++++++++++ .../warehouseShipTimelinessNotify.test.js | 282 ++++++++ frontend/src/api/warehouseShipTimeliness.js | 25 + .../pages/WarehouseShipTimeliness/index.jsx | 411 ++++++++++- .../pages/WarehouseShipTimeliness/style.css | 67 ++ 8 files changed, 1905 insertions(+), 4 deletions(-) create mode 100644 backend/src/jobs/warehouseShipTimelinessNotifyJob.js create mode 100644 backend/src/services/warehouseShipTimelinessNotifySettings.js create mode 100644 backend/test/warehouseShipTimelinessNotify.test.js diff --git a/backend/src/api/warehouseShipTimeliness.js b/backend/src/api/warehouseShipTimeliness.js index a37902e..d8d82f6 100644 --- a/backend/src/api/warehouseShipTimeliness.js +++ b/backend/src/api/warehouseShipTimeliness.js @@ -11,12 +11,26 @@ * GET /api/warehouse-ship-timeliness/rounds * 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回) * + * ── 两轮核验完成后推钉钉群(2026-10-27 新增)──────────────────────────────── + * GET /api/warehouse-ship-timeliness/notify/settings 读配置(加签密钥只回掩码) + * PUT /api/warehouse-ship-timeliness/notify/settings 保存配置(加签密钥留空 = 不变) + * GET /api/warehouse-ship-timeliness/notify/state 上次推送结果 + * POST /api/warehouse-ship-timeliness/notify/run 立即推送一次(dryRun / probe / force) + * * 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware */ const express = require('express'); const { getShipTimeliness } = require('../services/warehouseShipTimelinessService'); const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry'); +const { + getShipTimelinessNotifyAdminConfig, + getShipTimelinessNotifyRuntimeConfig, + loadShipTimelinessNotifyState, + missingFields, + saveShipTimelinessNotifyConfig, +} = require('../services/warehouseShipTimelinessNotifySettings'); +const { pushShipTimelinessNotify } = require('../jobs/warehouseShipTimelinessNotifyJob'); const router = express.Router(); @@ -59,4 +73,78 @@ router.get('/rounds', async (_req, res) => { } }); +// ── 两轮核验完成后推钉钉群 ────────────────────────────────────────────────── + +// 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端 +router.get('/notify/settings', async (_req, res) => { + try { + const [config, state] = await Promise.all([ + getShipTimelinessNotifyAdminConfig(), + loadShipTimelinessNotifyState(), + ]); + res.json({ success: true, data: { ...config, lastState: state || {} } }); + } catch (error) { + sendError(res, error, '读取发货时效通知配置失败'); + } +}); + +// 保存配置;加签密钥留空表示「沿用已存的」,不必每次重填 +router.put('/notify/settings', async (req, res) => { + try { + const data = await saveShipTimelinessNotifyConfig(req.body || {}); + res.json({ success: true, data }); + } catch (error) { + const status = Number(error?.status) || 400; + console.error('[WarehouseShipTimeliness] 保存通知配置失败:', error?.message); + res.status(status).json({ success: false, error: error.message || '保存发货时效通知配置失败' }); + } +}); + +router.get('/notify/state', async (_req, res) => { + try { + const state = await loadShipTimelinessNotifyState(); + res.json({ success: true, data: state || {} }); + } catch (error) { + sendError(res, error, '读取推送状态失败'); + } +}); + +/** + * 立即推送一次(页面「立即推送」/「预览正文」/「测试通道」)。 + * body: + * round 'NOON' | 'AFTERNOON',不传按当前时刻自动判定 + * dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径) + * probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通) + * force true = 忽略「本轮已推过」(默认手动触发就是 true,除非显式给 false) + */ +router.post('/notify/run', async (req, res) => { + try { + const body = req.body || {}; + const dryRun = body.dryRun === true || body.dryRun === 'true'; + const probe = body.probe === true || body.probe === 'true'; + const force = body.force === undefined ? true : (body.force === true || body.force === 'true'); + if (!dryRun) { + const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true }); + const missing = missingFields(config); + if (missing.length) { + res.status(400).json({ + success: false, + error: `请先补全并保存:${missing.join(' / ')}`, + data: { missingFields: missing }, + }); + return; + } + } + const result = await pushShipTimelinessNotify({ + dryRun, + probe, + force, + round: body.round || undefined, + }); + res.json({ success: true, data: result }); + } catch (error) { + sendError(res, error, '推送发货时效通知失败'); + } +}); + module.exports = router; diff --git a/backend/src/index.js b/backend/src/index.js index 28a006b..3177b2e 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -72,6 +72,9 @@ const { const { startWarehouseLogisticsExceptionNotifyScheduler, } = require('./jobs/warehouseLogisticsExceptionNotifyJob'); +const { + startShipTimelinessNotifyScheduler, +} = require('./jobs/warehouseShipTimelinessNotifyJob'); const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); const { ensureLocalSchema } = require('./services/localSchema'); const { @@ -798,6 +801,7 @@ async function startServer(options = {}) { startAlibaba1688ImageSearchExpiryScheduler(); startMkhReplenishAlertScheduler(); startWarehouseLogisticsExceptionNotifyScheduler(); + startShipTimelinessNotifyScheduler(); startDailyInventorySnapshotScheduler(); // Keep the first selection-pool page request from paying the remote schema diff --git a/backend/src/jobs/warehouseShipTimelinessNotifyJob.js b/backend/src/jobs/warehouseShipTimelinessNotifyJob.js new file mode 100644 index 0000000..d7397ad --- /dev/null +++ b/backend/src/jobs/warehouseShipTimelinessNotifyJob.js @@ -0,0 +1,659 @@ +'use strict'; + +/** + * 仓储中心 24 小时发货时效 · 两轮核验完成后把超时未发单推到钉钉群 + * ──────────────────────────────────────────────────────────────────────────── + * 业务口径(2026-10-27 原话): + * 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知; + * 18:00 核验的订单,核验完成后也通知。」 + * ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS): + * NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推 + * AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推 + * ⚠️ 默认「核验时刻 + 5 分钟」:状态镜像 `query_time` 得晚于核验时刻, + * 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。 + * 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。 + * + * 消息内容: + * 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等) + * + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。 + * 明细一行 = 仓|内部单号|线上单号|审核 HH:mm|已等 X 小时(业务对账用)。 + * 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。 + * + * 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。 + * 推送成功才写状态;发送失败绝不写,否则业务配好通道后反而不推了。 + * + * 时区:生产容器是 UTC,一切「今天 / 现在几点」都用 Asia/Shanghai 判。 + */ + +const cron = require('node-cron'); +const { getShipTimeliness, DEFAULT_DETAIL_LIMIT } = require('../services/warehouseShipTimelinessService'); +const { ROUNDS, 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} 天`; +} + +/** + * 审核时间的展示:本轮的日期就只给 HH:mm,跨天(历史未发单)必须带 MM-DD。 + * 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」, + * 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」, + * 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。 + */ +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,仓管在系统里按它查单),第三段是线上单号(客服对客户)。 + * 两者业务都要,别只留一个。 + * ⚠️ 审核时间跨天要带日期,见 auditWhen 的说明。 + */ +function buildDetailLine(row, index, roundDate) { + const parts = [ + cleanCell(row?.warehouseName || row?.centerName, '未知仓'), + cleanCell(row?.oId, '无单号'), + cleanCell(row?.soId, '—'), + `审核 ${auditWhen(row?.auditTime, roundDate)}`, + `已等 ${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) { + head.push(`已剔除 ${carrierSkipped} 单不考核快递(自提 / 中铁 / 壹米滴答)`); + } + // 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单 + 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 = {}) { + const now = options.now === undefined ? new Date() : new Date(options.now); + const pushDate = chinaDateKey(now); + 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(now); + 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, now); + if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey }; + + // 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick) + const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16)); + if (deadlineMinutes != null && minutesOfDay(now) < 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, + }); + 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: new Date(now).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: new Date(now).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, +}; diff --git a/backend/src/services/warehouseShipTimelinessNotifySettings.js b/backend/src/services/warehouseShipTimelinessNotifySettings.js new file mode 100644 index 0000000..9426313 --- /dev/null +++ b/backend/src/services/warehouseShipTimelinessNotifySettings.js @@ -0,0 +1,373 @@ +'use strict'; + +/** + * 「仓储中心 24 小时发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取 + * ──────────────────────────────────────────────────────────────────────────── + * 业务口径(2026-10-27): + * 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」 + * ⟹ **一天两推**,各自绑一个核验轮次: + * NOON 13:00 核验(窗口上界 07:00 前审核)→ 默认 13:05 推 + * AFTERNOON 18:00 核验(窗口上界 16:00 前审核)→ 默认 18:05 推 + * ⚠️ 默认给到「核验时刻 + 5 分钟」而不是整点:状态镜像 `query_time` 要略晚于核验时刻, + * 否则 `freshness.coversDeadline=false`(数据没覆盖本轮核验点),推出去的超时数必然偏大。 + * 业务要改时刻在页面上改,不必重启后端。 + * + * 通道 = 钉钉群自定义机器人 webhook(与「物流转运异常」同一套,只发 text、不发附件)。 + * 配置分两层存(与 yydNotificationCenterSettings 同一套做法): + * · 普通字段 → 本地库 `system_config`,key = warehouse_ship_timeliness_notify_config + * · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件) + * + * 状态另存一份(key = warehouse_ship_timeliness_notify_state),按 `<日期>|<轮次键>` 去重 —— + * 服务重启、每分钟 tick 重复触发都不会重复轰炸;页面「立即推送」才强制重发。 + */ + +const database = require('../config/database'); +const { + getResilientSecret, + saveResilientSecret, +} = require('./resilientSecretStore'); + +const CONFIG_KEY = 'warehouse_ship_timeliness_notify_config'; +const SECRET_KEY = 'warehouse_ship_timeliness_notify_webhook_secret'; +const STATE_KEY = 'warehouse_ship_timeliness_notify_state'; + +// 两个轮次的默认推送时刻(核验 13:00 / 18:00 + 5 分钟,见文件头说明) +const DEFAULT_SEND_TIME_NOON = '13:05'; +const DEFAULT_SEND_TIME_AFTERNOON = '18:05'; + +// 明细条数:text 上限 20000 字节,30 条中文明细约 9~12KB,留足余量 +const DEFAULT_MAX_DETAIL_ROWS = 30; +const MAX_DETAIL_ROWS_LIMIT = 50; + +// 页面地址(消息末尾「查看全部」用)。生产入口带 8443,别写成裸域名 +const DEFAULT_PAGE_URL = process.env.WST_PAGE_URL + || 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness'; + +const CACHE_TTL_MS = 30 * 1000; + +// 状态里最多保留多少条「日期|轮次」记录(一天两条,30 天足够,避免 system_config 无限长) +const MAX_STATE_ENTRIES = 60; + +let cachedConfig = null; +let cachedAt = 0; + +function normalizeText(value, maxLength = 512) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function safeJsonParse(value, fallback = {}) { + if (value && typeof value === 'object') return value; + try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; } +} + +function maskSecret(value) { + const text = normalizeText(value); + if (!text) return ''; + if (text.length <= 8) return `${text.slice(0, 2)}******`; + return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`; +} + +/** HH:mm(00:00 ~ 23:59);非法输入退回默认值,不抛错(避免把定时任务配挂) */ +function normalizeSendTime(value, fallback = DEFAULT_SEND_TIME_NOON) { + const text = normalizeText(value, 5); + if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text; + return fallback; +} + +/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */ +function normalizeMaxDetailRows(value) { + const num = Number(value); + if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS; + return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num))); +} + +function normalizePageUrl(value) { + const text = normalizeText(value, 1024); + if (!text) return DEFAULT_PAGE_URL; + try { + const url = new URL(text); + if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL; + return url.toString(); + } catch (_error) { + return DEFAULT_PAGE_URL; + } +} + +function normalizeBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const text = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(text)) return true; + if (['0', 'false', 'no', 'off'].includes(text)) return false; + return fallback; +} + +async function upsertConfigRow(value) { + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + const text = JSON.stringify(value); + if (rows[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [text, 'json', CONFIG_KEY] + ); + } else { + await database.local.insert('system_config', { + config_key: CONFIG_KEY, + config_value: text, + config_type: 'json', + }); + } +} + +async function readConfigRow() { + try { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + return safeJsonParse(rows[0]?.configValue, {}); + } catch (error) { + console.warn('[WstNotify] 读取推送配置失败:', error.message); + return {}; + } +} + +/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */ +async function getShipTimelinessNotifyRuntimeConfig({ forceRefresh = false } = {}) { + if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig }; + + const stored = await readConfigRow(); + const secret = await getResilientSecret(SECRET_KEY, { + legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)], + fallbackValues: [normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)], + }); + + cachedConfig = { + enabled: normalizeBoolean(stored.enabled, false), + webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024) + || normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK, 1024), + webhookSecret: secret.value, + webhookSecretSource: secret.source, + // 两轮各自的推送时刻 + noonSendTime: normalizeSendTime( + stored.noonSendTime || stored.noon_send_time, + DEFAULT_SEND_TIME_NOON + ), + afternoonSendTime: normalizeSendTime( + stored.afternoonSendTime || stored.afternoon_send_time, + DEFAULT_SEND_TIME_AFTERNOON + ), + maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows), + atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false), + pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url), + source: Object.keys(stored).length > 0 ? 'database' : 'default', + }; + cachedAt = Date.now(); + return { ...cachedConfig }; +} + +/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/ +function missingFields(config = {}) { + const missing = []; + if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址'); + return missing; +} + +function isShipTimelinessNotifyReady(config = {}) { + return missingFields(config).length === 0; +} + +function toAdminConfig(config = {}) { + return { + enabled: config.enabled === true, + webhookUrl: config.webhookUrl || '', + webhookSecretConfigured: Boolean(config.webhookSecret), + webhookSecretPreview: maskSecret(config.webhookSecret), + webhookSecretSource: config.webhookSecretSource || 'unconfigured', + noonSendTime: normalizeSendTime(config.noonSendTime, DEFAULT_SEND_TIME_NOON), + afternoonSendTime: normalizeSendTime(config.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON), + maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows), + maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT, + atAll: config.atAll === true, + pageUrl: normalizePageUrl(config.pageUrl), + ready: isShipTimelinessNotifyReady(config), + missingFields: missingFields(config), + source: config.source || 'default', + }; +} + +async function getShipTimelinessNotifyAdminConfig() { + const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true }); + return toAdminConfig(config); +} + +/** + * 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填); + * 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。 + * 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。 + */ +async function saveShipTimelinessNotifyConfig(input = {}) { + const current = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true }); + const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512); + const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true; + + const config = { + enabled: normalizeBoolean(input.enabled, current.enabled), + webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl, + webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret), + noonSendTime: input.noonSendTime === undefined && input.noon_send_time === undefined + ? normalizeSendTime(current.noonSendTime, DEFAULT_SEND_TIME_NOON) + : normalizeSendTime(input.noonSendTime ?? input.noon_send_time, DEFAULT_SEND_TIME_NOON), + afternoonSendTime: input.afternoonSendTime === undefined && input.afternoon_send_time === undefined + ? normalizeSendTime(current.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON) + : normalizeSendTime(input.afternoonSendTime ?? input.afternoon_send_time, DEFAULT_SEND_TIME_AFTERNOON), + maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined + ? normalizeMaxDetailRows(current.maxDetailRows) + : normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows), + atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true), + pageUrl: input.pageUrl === undefined && input.page_url === undefined + ? normalizePageUrl(current.pageUrl) + : normalizePageUrl(input.pageUrl ?? input.page_url), + }; + + if (config.enabled) { + const missing = missingFields(config); + if (missing.length) { + throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 }); + } + } + + if (config.webhookUrl) { + // 这里只做「像不像」的浅校验;真正的连通性由「测试通道」按钮验证 + const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender'); + try { + config.webhookUrl = normalizeWebhookUrl(config.webhookUrl); + } catch (error) { + throw Object.assign(new Error(error.message), { status: 400 }); + } + } + + if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret); + await upsertConfigRow({ + enabled: config.enabled, + webhookUrl: config.webhookUrl, + noonSendTime: config.noonSendTime, + afternoonSendTime: config.afternoonSendTime, + maxDetailRows: config.maxDetailRows, + atAll: config.atAll, + pageUrl: config.pageUrl, + updatedAt: new Date().toISOString(), + }); + + cachedConfig = { + ...config, + webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')), + source: 'database', + }; + cachedAt = Date.now(); + return toAdminConfig(cachedConfig); +} + +/** + * 推送状态。 + * 结构(version 1): + * { + * version: 1, + * sent: { '2026-10-27|NOON': { sentAt, rowCount, listedCount, bytes, deadline } , ... }, + * lastSentAt, lastStatus, lastError, lastFailedAt, lastRoundLabel + * } + * sent 只保留最近 MAX_STATE_ENTRIES 条(一天两轮,30 天足够)。 + */ +async function loadShipTimelinessNotifyState() { + try { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [STATE_KEY] + ); + const state = safeJsonParse(rows[0]?.configValue, {}); + if (!state || typeof state !== 'object') return {}; + return { ...state, sent: (state.sent && typeof state.sent === 'object') ? state.sent : {} }; + } catch (error) { + console.warn('[WstNotify] 读取推送状态失败:', error.message); + return {}; + } +} + +/** 去重键:`<北京日期>|<轮次键>` —— 一天两轮各自独立 */ +function buildPushSlot(dateKey, roundKey) { + const date = normalizeText(dateKey, 10); + const round = normalizeText(roundKey, 20).toUpperCase(); + if (!date || !round) return ''; + return `${date}|${round}`; +} + +async function saveShipTimelinessNotifyState(next = {}) { + const value = JSON.stringify(next && typeof next === 'object' ? next : {}); + try { + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [STATE_KEY] + ); + if (rows[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [value, 'json', STATE_KEY] + ); + } else { + await database.local.insert('system_config', { + config_key: STATE_KEY, + config_value: value, + config_type: 'json', + }); + } + } catch (error) { + console.warn('[WstNotify] 保存推送状态失败:', error.message); + } +} + +/** 把 sent 裁剪到 MAX_STATE_ENTRIES 条,按 slot 日期倒序保留(新日期优先) */ +function trimSentMap(sent = {}) { + const entries = Object.entries(sent || {}); + if (entries.length <= MAX_STATE_ENTRIES) return sent; + entries.sort((a, b) => { + const da = String(a[1]?.sentAt || ''); + const db = String(b[1]?.sentAt || ''); + return db.localeCompare(da); + }); + return Object.fromEntries(entries.slice(0, MAX_STATE_ENTRIES)); +} + +function clearShipTimelinessNotifyConfigCache() { + cachedConfig = null; + cachedAt = 0; +} + +module.exports = { + CONFIG_KEY, + DEFAULT_MAX_DETAIL_ROWS, + DEFAULT_PAGE_URL, + DEFAULT_SEND_TIME_AFTERNOON, + DEFAULT_SEND_TIME_NOON, + MAX_DETAIL_ROWS_LIMIT, + MAX_STATE_ENTRIES, + SECRET_KEY, + STATE_KEY, + buildPushSlot, + clearShipTimelinessNotifyConfigCache, + getShipTimelinessNotifyAdminConfig, + getShipTimelinessNotifyRuntimeConfig, + isShipTimelinessNotifyReady, + loadShipTimelinessNotifyState, + maskSecret, + missingFields, + normalizeMaxDetailRows, + normalizePageUrl, + normalizeSendTime, + saveShipTimelinessNotifyConfig, + saveShipTimelinessNotifyState, + toAdminConfig, + trimSentMap, +}; diff --git a/backend/test/warehouseShipTimelinessNotify.test.js b/backend/test/warehouseShipTimelinessNotify.test.js new file mode 100644 index 0000000..0d96699 --- /dev/null +++ b/backend/test/warehouseShipTimelinessNotify.test.js @@ -0,0 +1,282 @@ +'use strict'; + +/** + * 「仓储中心 24 小时发货时效」两轮核验完成后推钉钉群 —— 正文组装与调度回归测试 + * ──────────────────────────────────────────────────────────────────────────── + * 业务口径(2026-10-27):「13 点核验 7 点前的超时单,核验完成后通知; + * 18:00 核验的订单,核验完成后也通知。」⟹ 一天两推,各绑一个轮次。 + * + * 这里只测五件容易悄悄坏掉的事: + * ① 两个轮次各自的推送时刻能不能正确命中(含「配成同一时刻」的边界); + * ② 群消息正文的数字与明细(业务照着这条消息追单,数字错了没人能发现); + * ③ 0 单超时也要推(群里看到「本轮 0 单」= 任务没挂); + * ④ 明细按仓保底 + 超长精简(钉钉 text 上限 20000 字节,超了整条被拒); + * ⑤ 去重键必须「日期 + 轮次」,一天两轮互不干扰。 + * + * ⚠️ 本文件不连数据库、不发网络请求,纯逻辑测试。 + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const job = require('../src/jobs/warehouseShipTimelinessNotifyJob'); +const settings = require('../src/services/warehouseShipTimelinessNotifySettings'); + +function sampleData(overrides = {}) { + return { + round: { key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEnd: '2026-10-27 07:00:00' }, + deadline: '2026-10-27 13:00:00', + date: '2026-10-27', + window: { start: '2026-10-07 00:00:00', end: '2026-10-27 07:00:00', includeHistory: true, boundary: 'audit' }, + summary: { + overdueUnshipped: 3, + overdueCenterCount: 2, + oldestDays: 4, + heldCount: 5, + voidCount: 12, + carrierSkipped: 2, + }, + centers: [ + { key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 2, oldestDays: 4 }, + { key: 'HL2', name: '横林二仓', overdueUnshipped: 1, oldestDays: 1 }, + { key: 'SN', name: '睢宁仓', overdueUnshipped: 0, oldestDays: 0 }, + ], + orders: [ + { + oId: '9855123', + soId: '261021-301318827670255', + shopName: '京东旗舰店', + warehouseName: '简尚-崔桥工厂仓', + centerName: '崔桥工厂仓', + auditTime: '2026-10-27 06:32:11', + overdueMinutes: 388, + }, + { + oId: '9855200', + soId: 'SO20261027001', + shopName: '天猫店', + warehouseName: '简尚-崔桥工厂仓', + centerName: '崔桥工厂仓', + auditTime: '2026-10-26 09:05:00', + overdueMinutes: 2875, + }, + { + oId: '9855300', + soId: '', + shopName: '抖音店', + warehouseName: '简尚-横林二仓', + centerName: '横林二仓', + auditTime: '', + overdueMinutes: 120, + }, + ], + freshness: { stateMaxQuery: '2026-10-27 13:04:00', coversDeadline: true, stale: false }, + ...overrides, + }; +} + +function config(overrides = {}) { + return { + enabled: true, + webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test', + webhookSecret: '', + noonSendTime: '13:05', + afternoonSendTime: '18:05', + maxDetailRows: 30, + atAll: false, + pageUrl: 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness', + ...overrides, + }; +} + +/** 构造北京时间某个时刻(本机是东八区,直接 new Date(本地字段) 即可) */ +function bj(year, month, day, hour, minute) { + return new Date(year, month - 1, day, hour, minute, 0, 0); +} + +// ── ① 两个轮次各自的推送时刻 ────────────────────────────────────────────── +test('两个轮次按各自配置的时刻命中', () => { + const cfg = config(); + assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON']); + assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 18, 5)), ['AFTERNOON']); + // 非命中时刻一律不推(免得每分钟都轰炸一次) + assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 6)), []); + assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 12, 59)), []); +}); + +test('两个时刻配成同一个 HH:mm 时,两轮都要推(不能只推第一轮)', () => { + const cfg = config({ noonSendTime: '13:05', afternoonSendTime: '13:05' }); + assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON', 'AFTERNOON']); +}); + +test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补', () => { + const cfg = config(); + assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: true }), ['NOON']); + assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 40), { catchUp: true }), []); + // 关掉补推时,错过就错过 + assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: false }), []); +}); + +// ── ② 正文内容 ──────────────────────────────────────────────────────────── +test('正文包含轮次、超时单数、涉及仓数、最久天数', () => { + const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); + assert.match(built.text, /【仓储中心发货时效】13:00 轮/); + assert.match(built.text, /核验 07:00 前审核/); + assert.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/); + assert.equal(built.rowCount, 3); +}); + +test('按仓块只列超时 > 0 的仓,并按单数降序', () => { + const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); + assert.match(built.text, /按仓(超时单):/); + assert.match(built.text, /1\. 崔桥工厂仓 2 单(最久 4 天)/); + assert.match(built.text, /2\. 横林二仓 1 单(最久 1 天)/); + assert.doesNotMatch(built.text, /睢宁仓 0 单/); +}); + +test('明细一行 = 仓|内部单号|线上单号|审核 时间|已等', () => { + const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); + assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|审核 06:32|已等 6 小时 28 分/); + // 无线上单号 → 「—」;无审核时间 → 「未审核」;都没有时不能出现 undefined + assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|审核 未审核|已等 2 小时/); + assert.doesNotMatch(built.text, /undefined|NaN/); +}); + +test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => { + // 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单 + const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); + assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|审核 10-26 09:05|已等 1 天 23 小时/); + // 同一天的就只给 HH:mm,别啰嗦 + assert.match(built.text, /审核 06:32/); + assert.doesNotMatch(built.text, /审核 10-27 06:32/); + // 直接测函数本身的跨天判定 + assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32'); + assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13'); + assert.equal(job.auditWhen('', '2026-10-27'), '未审核'); +}); + +test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => { + const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); + assert.match(built.text, /另有 5 单不计考核/); + assert.match(built.text, /已剔除 2 单不考核快递/); + assert.doesNotMatch(built.text, /⚠️/); + + const stale = job.buildNotifyText(sampleData({ + freshness: { stateMaxQuery: '2026-10-27 12:31:00', coversDeadline: false, stale: true }, + }), { maxDetailRows: 30 }); + assert.match(stale.text, /⚠️ 数据快照只到 12:31/); + assert.match(stale.text, /尚未覆盖本轮核验时刻 13:00/); + assert.match(stale.text, /超时单量偏大/); +}); + +// ── ③ 0 单也要推 ────────────────────────────────────────────────────────── +test('本轮 0 单超时:发「全部按时发出」,不带空明细块', () => { + const built = job.buildNotifyText(sampleData({ + summary: { overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, heldCount: 0, voidCount: 3, carrierSkipped: 0 }, + centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 0, oldestDays: 0 }], + orders: [], + }), { maxDetailRows: 30 }); + assert.match(built.text, /超时未发货 0 单,涉及 0 个仓/); + assert.match(built.text, /本轮没有超时未发货的订单,全部按时发出/); + assert.doesNotMatch(built.text, /明细前/); + assert.doesNotMatch(built.text, /查看全部/); + assert.equal(built.rowCount, 0); + assert.equal(built.listedCount, 0); +}); + +// ── ④ 明细额度:按仓保底 + 超长精简 ─────────────────────────────────────── +test('明细额度按仓保底:一个仓吃不满额度,其它仓至少 1 条', () => { + const many = []; + for (let i = 0; i < 40; i += 1) { + many.push({ oId: `A${i}`, soId: '', centerName: '崔桥工厂仓', warehouseName: '简尚-崔桥工厂仓', overdueMinutes: 600 + i }); + } + for (let i = 0; i < 2; i += 1) { + many.push({ oId: `B${i}`, soId: '', centerName: '横林二仓', warehouseName: '简尚-横林二仓', overdueMinutes: 300 }); + } + const picked = job.pickDetailRows(many, 6); + assert.equal(picked.length, 6); + // 每仓保底 1 条,剩余额度按各仓条数比例分(40:2 ⇒ 5:1) + assert.equal(picked.filter((row) => row.centerName === '横林二仓').length, 1); + assert.equal(picked.filter((row) => row.centerName === '崔桥工厂仓').length, 5); + // 组间按仓聚集:条数多的仓排前面,所以首行是崔桥 + assert.equal(picked[0].centerName, '崔桥工厂仓'); +}); + +test('明细条数 0 = 只发汇总不带明细', () => { + const built = job.buildNotifyText(sampleData(), { maxDetailRows: 0 }); + assert.equal(built.listedCount, 0); + assert.match(built.text, /明细共 3 条,消息里不放/); +}); + +test('超长时从明细末尾往前砍,砍到能发为止', () => { + // 故意把线上单号拉长(真实数据没见过,但业务改过列宽/加字段后有可能), + // 保证 50 条明细 + 分仓块必然超过 20000 字节上限 + const rows = []; + for (let i = 0; i < 300; i += 1) { + rows.push({ + oId: `98${String(i).padStart(6, '0')}`, + soId: `SO-2026-10-27-${String(i).padStart(6, '0')}-${'X'.repeat(360)}`, + centerName: '崔桥工厂仓', + warehouseName: '简尚-崔桥工厂仓', + auditTime: '2026-10-27 06:32:11', + overdueMinutes: 400 + i, + }); + } + const built = job.buildNotifyText(sampleData({ + summary: { overdueUnshipped: 300, overdueCenterCount: 1, oldestDays: 4, heldCount: 0, voidCount: 0, carrierSkipped: 0 }, + centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 300, oldestDays: 4 }], + orders: rows, + }), { maxDetailRows: 50 }); + assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, `bytes=${built.bytes} 超上限`); + assert.equal(built.truncated, true); + assert.ok(built.listedCount < 50); + assert.match(built.text, /其余 \d+ 条见页面/); +}); + +// ── ⑤ 去重键 ────────────────────────────────────────────────────────────── +test('去重键是「日期|轮次」,两轮互不覆盖', () => { + const noon = settings.buildPushSlot('2026-10-27', 'NOON'); + const afternoon = settings.buildPushSlot('2026-10-27', 'AFTERNOON'); + assert.equal(noon, '2026-10-27|NOON'); + assert.equal(afternoon, '2026-10-27|AFTERNOON'); + assert.notEqual(noon, afternoon); + // 小写也归一 + assert.equal(settings.buildPushSlot('2026-10-27', 'noon'), '2026-10-27|NOON'); + assert.equal(settings.buildPushSlot('', 'NOON'), ''); +}); + +test('推送时刻归一化:非法值退回默认,不抛错', () => { + assert.equal(settings.normalizeSendTime('13:05'), '13:05'); + assert.equal(settings.normalizeSendTime('8:5'), '13:05'); + assert.equal(settings.normalizeSendTime('25:00'), '13:05'); + assert.equal(settings.normalizeSendTime(''), '13:05'); + assert.equal(settings.normalizeSendTime('', '18:05'), '18:05'); + assert.equal(settings.DEFAULT_SEND_TIME_NOON, '13:05'); + assert.equal(settings.DEFAULT_SEND_TIME_AFTERNOON, '18:05'); +}); + +test('超长文案格式化:分钟 / 小时 / 跨天', () => { + assert.equal(job.formatOverdue(30), '30 分钟'); + assert.equal(job.formatOverdue(120), '2 小时'); + assert.equal(job.formatOverdue(388), '6 小时 28 分'); + assert.equal(job.formatOverdue(1500), '1 天 1 小时'); + assert.equal(job.formatOverdue(0), '—'); + assert.equal(job.formatOverdue(null), '—'); +}); + +test('页面链接带上轮次参数,业务点进去看的就是这一轮', () => { + const link = job.buildPageLink('https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness', 'AFTERNOON'); + assert.equal(link, 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness?round=AFTERNOON'); + const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 }); + assert.match(built.text, /\?round=NOON/); +}); + +test('轮次元信息:13:00 轮核验 07:00、18:00 轮核验 16:00', () => { + const noon = job.resolveRoundMeta('NOON', bj(2026, 10, 27, 13, 5)); + const afternoon = job.resolveRoundMeta('AFTERNOON', bj(2026, 10, 27, 18, 5)); + assert.equal(noon.deadline, '2026-10-27 13:00:00'); + assert.equal(noon.windowEnd, '2026-10-27 07:00:00'); + assert.equal(afternoon.deadline, '2026-10-27 18:00:00'); + assert.equal(afternoon.windowEnd, '2026-10-27 16:00:00'); + assert.equal(job.resolveRoundMeta('UNKNOWN', bj(2026, 10, 27, 13, 5)), null); +}); diff --git a/frontend/src/api/warehouseShipTimeliness.js b/frontend/src/api/warehouseShipTimeliness.js index 7f313f6..89a35d4 100644 --- a/frontend/src/api/warehouseShipTimeliness.js +++ b/frontend/src/api/warehouseShipTimeliness.js @@ -21,3 +21,28 @@ export const getWarehouseShipTimelinessRounds = () => request.get( '/api/warehouse-ship-timeliness/rounds', { timeout: 30000 } ); + +// ── 两轮核验完成后推文本到钉钉群(2026-10-27 新增)──────────────────────── +// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选) +// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview) +export const getShipTimelinessNotifySettings = () => request.get( + '/api/warehouse-ship-timeliness/notify/settings', + { timeout: 30000, headers: { 'Cache-Control': 'no-cache' } } +); + +// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填 +export const saveShipTimelinessNotifySettings = (payload = {}) => request.put( + '/api/warehouse-ship-timeliness/notify/settings', + payload, + { timeout: 30000 } +); + +// 立即推送 / 预览正文 / 测试通道 +// dryRun=true 只组装消息不发送(返回正文全文) +// probe=true 只发一句通道测试,不查业务数据 +// 冷启动要全量扫订单状态视图(生产实测数十秒到几分钟),超时给足 +export const runShipTimelinessNotify = (payload = {}) => request.post( + '/api/warehouse-ship-timeliness/notify/run', + payload, + { timeout: 10 * 60 * 1000 } +); diff --git a/frontend/src/pages/WarehouseShipTimeliness/index.jsx b/frontend/src/pages/WarehouseShipTimeliness/index.jsx index 6d1db01..3b72b17 100644 --- a/frontend/src/pages/WarehouseShipTimeliness/index.jsx +++ b/frontend/src/pages/WarehouseShipTimeliness/index.jsx @@ -1,10 +1,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - Alert, Button, Card, Empty, Modal, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, + Alert, Button, Card, Collapse, Empty, Input, InputNumber, Modal, Space, Spin, Statistic, + Switch, Table, Tag, TimePicker, Tooltip, Typography, message, } from 'antd'; -import { ReloadOutlined } from '@ant-design/icons'; +import { BellOutlined, ReloadOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; import { SycmPage } from '../../components/Common'; -import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness'; +import { + getShipTimelinessNotifySettings, + getWarehouseShipTimeliness, + runShipTimelinessNotify, + saveShipTimelinessNotifySettings, +} from '../../api/warehouseShipTimeliness'; import './style.css'; const { Title, Text } = Typography; @@ -13,6 +20,88 @@ const { Title, Text } = Typography; // 接口要扫两张订单表 + 出库表(rpt 下全是视图),冷启动 40~60 秒,别跟着其它页面的轮询节奏跑。 const AUTO_REFRESH_MS = 10 * 60 * 1000; +// 「钉钉通知」表单初始值。两个轮次各一个推送时刻(默认核验点 + 5 分钟)。 +// 加签密钥永远不回显明文:留空 = 沿用服务端已存的那份 +const EMPTY_NOTIFY_FORM = { + enabled: false, + noonSendTime: '13:05', + afternoonSendTime: '18:05', + webhookUrl: '', + webhookSecret: '', + maxDetailRows: 30, + atAll: false, + pageUrl: '', +}; + +// 后端「没推」的原因 → 人话(后端 reason 是给日志用的英文标识) +const NOTIFY_REASON_TEXT = { + disabled: '推送开关没打开', + not_configured: 'Webhook 地址还没填', + already_sent: '这一轮今天已经推过了(同一天同一轮只推一次)', + not_started: '这一轮还没到核验时刻,没有结论可推', + before_deadline: '还没到本轮核验时刻,推到群里会是半成品', + dry_run: '仅预览,未发送', + error: '执行出错', +}; + +// 钉钉群机器人 webhook 怎么拿 —— 业务自己配,写清楚省得来问 +const NOTIFY_HELP_ITEMS = [ + { + key: 'webhook', + label: '① 拿群机器人 Webhook 地址', + children: ( +
+ 电脑版钉钉 → 打开目标群 → 右上角 群设置 → 机器人 → 添加机器人 → + 选 自定义 → 起个名字(如「发货时效通知」)→ 复制 Webhook 地址。 +
+ 地址形如 https://oapi.dingtalk.com/robot/send?access_token=xxxx, + 整串(含 access_token)直接贴到下面。 +
+ ), + }, + { + key: 'security', + label: '② 安全设置:加签密钥(可选)', + children: ( +
+ 添加机器人时如果安全设置选了 加签,把以 SEC 开头的密钥填到「加签密钥」里。 +
+ 如果选的是 自定义关键词,密钥留空即可 —— 但关键词必须包含 超时 + (本模块消息抬头固定是「【仓储中心发货时效】」,正文里有「超时未发货 N 单」,天然命中)。 +
+ 如果选的是 IP 地址(段),需要把生产服务器 IP 加进白名单,密钥同样留空。 +
+ ), + }, + { + key: 'round', + label: '③ 为什么一天推两次、时间怎么定', + children: ( +
+ 业务口径:13:00 核验 7 点前审核的超时单、18:00 核验 16 点前审核的单, + 各自核验完成后推一次,所以是两个独立时刻,互不影响。 +
+ 默认给到 13:05 / 18:05(核验点 + 5 分钟):聚水潭状态镜像要晚几分钟才覆盖到核验时刻, + 整点推容易报「数据没覆盖核验点、超时数偏大」。想改成整点也可以,消息里会带这句提醒。 +
+ 同一轮次同一天只推一次(服务重启不会重复轰炸);要重发点「立即推送」。 +
+ ), + }, + { + key: 'why', + label: '④ 为什么群里只有文字,没有 Excel 附件', + children: ( +
+ 钉钉的自定义机器人 webhook 只支持 text / markdown / link / actionCard, + 不支持发文件。所以这里推的是文本消息:汇总数字 + 前 N 条明细 + 页面链接。 +
+ 要全量明细或 Excel,点消息末尾的链接进页面查看 / 导出(口径与消息完全一致)。 +
+ ), + }, +]; + const number = (value, digits = 0) => ( value === null || value === undefined || value === '' ? '0' @@ -209,6 +298,16 @@ function WarehouseShipTimeliness() { // 「业务挂起(疑问单)」明细弹窗 const [heldOpen, setHeldOpen] = useState(false); + // ── 钉钉通知(两轮核验完成后各推一次)────────────────────────────────── + const [notifyOpen, setNotifyOpen] = useState(false); + const [notifyLoading, setNotifyLoading] = useState(false); + const [notifySaving, setNotifySaving] = useState(false); + const [notifyRunning, setNotifyRunning] = useState(false); + const [notifyConfig, setNotifyConfig] = useState(null); + const [notifyForm, setNotifyForm] = useState(EMPTY_NOTIFY_FORM); + // 预览正文:放在弹窗内受控渲染(React 19 下 Modal.info 静态方法弹不出来) + const [notifyPreview, setNotifyPreview] = useState(null); + // force=true 时带 force=1 让后端绕过 5 分钟结果缓存重查库; // 定时轮询走缓存(这张视图单次要几十秒,每轮都重查会把库压死) const load = useCallback(async (force = false) => { @@ -268,6 +367,132 @@ function WarehouseShipTimeliness() { activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders ), [activeCenter, orders]); + // ── 钉钉通知:配置读写 / 预览 / 立即推送 ───────────────────────────────── + + // 后端只回加签密钥掩码,所以表单里的 webhookSecret 永远是空的 —— 留空 = 沿用已存的 + const applyNotifyConfig = useCallback((config) => { + setNotifyConfig(config || null); + setNotifyForm({ + enabled: config?.enabled === true, + noonSendTime: config?.noonSendTime || '13:05', + afternoonSendTime: config?.afternoonSendTime || '18:05', + webhookUrl: config?.webhookUrl || '', + webhookSecret: '', + maxDetailRows: Number.isFinite(Number(config?.maxDetailRows)) + ? Number(config.maxDetailRows) + : 30, + atAll: config?.atAll === true, + pageUrl: config?.pageUrl || '', + }); + }, []); + + const openNotifySettings = useCallback(async () => { + setNotifyOpen(true); + setNotifyPreview(null); + setNotifyLoading(true); + try { + const result = await getShipTimelinessNotifySettings(); + if (!result || result.success === false) { + message.error(result?.error || '读取发货时效通知配置失败'); + return; + } + applyNotifyConfig(result.data); + } catch (loadError) { + message.error(loadError?.response?.data?.error || loadError?.message || '读取发货时效通知配置失败'); + } finally { + setNotifyLoading(false); + } + }, [applyNotifyConfig]); + + const buildNotifyPayload = useCallback(() => ({ + enabled: notifyForm.enabled, + noonSendTime: notifyForm.noonSendTime, + afternoonSendTime: notifyForm.afternoonSendTime, + webhookUrl: String(notifyForm.webhookUrl || '').trim(), + webhookSecret: String(notifyForm.webhookSecret || '').trim(), + maxDetailRows: Number(notifyForm.maxDetailRows) || 0, + atAll: notifyForm.atAll === true, + pageUrl: String(notifyForm.pageUrl || '').trim(), + }), [notifyForm]); + + const handleSaveNotify = async ({ silent = false } = {}) => { + setNotifySaving(true); + try { + const result = await saveShipTimelinessNotifySettings(buildNotifyPayload()); + if (!result || result.success === false) { + message.error(result?.error || '保存发货时效通知配置失败'); + return null; + } + applyNotifyConfig(result.data); + if (!silent) message.success('发货时效通知配置已保存'); + return result.data; + } catch (saveError) { + message.error(saveError?.response?.data?.error || saveError?.message || '保存发货时效通知配置失败'); + return null; + } finally { + setNotifySaving(false); + } + }; + + /** + * mode: 'dry' 预览正文(不发送)| 'probe' 只发一句通道测试 | 'send' 立即推送当前轮 + * 都要先保存:否则「改完 webhook 直接点测试」会拿旧地址发,排错时会很迷惑。 + * + * ⚠️ 预览**不能**用 `Modal.info` 静态方法:本项目跑 React 19,antd v5 的静态方法 + * 拿不到 ConfigProvider context(控制台只给一行 warning),弹窗**静默不渲染**。 + * 所以预览改成弹窗内的受控区块(notifyPreview)。 + */ + const handleRunNotify = async (mode) => { + const saved = await handleSaveNotify({ silent: true }); + if (!saved) return; + setNotifyRunning(true); + try { + const payload = mode === 'dry' + ? { dryRun: true, force: true } + : { probe: mode === 'probe', force: true }; + const result = await runShipTimelinessNotify(payload); + if (!result || result.success === false) { + message.error(result?.error || '推送到钉钉群失败'); + return; + } + const info = result.data || {}; + if (mode === 'dry') { + setNotifyPreview({ + text: info.text || '', + rowCount: info.rowCount ?? 0, + listedCount: info.listedCount ?? 0, + bytes: info.bytes || 0, + truncated: info.truncated === true, + roundLabel: info.roundLabel || '', + }); + message.success('已生成预览(未发送),正文见下方'); + } else if (mode === 'probe') { + message.success('通道测试已发出,去钉钉群看有没有收到那条测试消息'); + } else if (info.sent) { + setNotifyPreview(null); + message.success(`已推送到钉钉群(${info.roundLabel || info.round},超时 ${info.rowCount ?? 0} 单,列出 ${info.listedCount ?? 0} 条)`); + } else { + message.warning(`未推送:${NOTIFY_REASON_TEXT[info.reason] || info.reason || '未知原因'}`); + } + const latest = await getShipTimelinessNotifySettings(); + if (latest?.data) applyNotifyConfig(latest.data); + } catch (runError) { + message.error(runError?.response?.data?.error || runError?.message || '推送到钉钉群失败'); + } finally { + setNotifyRunning(false); + } + }; + + const notifyLastState = notifyConfig?.lastState || {}; + const notifyNoonValue = useMemo( + () => (notifyForm.noonSendTime ? dayjs(notifyForm.noonSendTime, 'HH:mm') : null), + [notifyForm.noonSendTime] + ); + const notifyAfternoonValue = useMemo( + () => (notifyForm.afternoonSendTime ? dayjs(notifyForm.afternoonSendTime, 'HH:mm') : null), + [notifyForm.afternoonSendTime] + ); + return (
@@ -327,6 +552,7 @@ function WarehouseShipTimeliness() { {data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s,点刷新重查)` : ''} ) : null} + @@ -505,7 +731,7 @@ function WarehouseShipTimeliness() { onCancel={() => setHeldOpen(false)} footer={null} width={1080} - destroyOnClose + destroyOnHidden > + setNotifyOpen(false)} + width={760} + destroyOnHidden + maskClosable={false} + styles={{ body: { maxHeight: '62vh', overflowY: 'auto' } }} + footer={[ + , + , + , + , + , + ]} + > + + + + 缺:{(notifyConfig?.missingFields || []).join(' / ') || '(尚未读取)'} + + )} + /> + +
+ 开启推送 + setNotifyForm((old) => ({ ...old, enabled: checked }))} + checkedChildren="开" + unCheckedChildren="关" + /> + 关掉后定时任务不推,但「立即推送」仍可用(方便先试口径) +
+ +
+ 13:00 轮 + { + if (!value) return; + setNotifyForm((old) => ({ ...old, noonSendTime: value.format('HH:mm') })); + }} + /> + 核验「今天 07:00 前审核」的超时单,默认核验点 + 5 分钟 +
+ +
+ 18:00 轮 + { + if (!value) return; + setNotifyForm((old) => ({ ...old, afternoonSendTime: value.format('HH:mm') })); + }} + /> + 核验「截止 16:00 前审核」的超时单(含历史未发),默认核验点 + 5 分钟 +
+ +
+ Webhook + setNotifyForm((old) => ({ ...old, webhookUrl: e.target.value }))} + style={{ flex: 1, minWidth: 380 }} + /> +
+ +
+ 加签密钥 + setNotifyForm((old) => ({ ...old, webhookSecret: e.target.value }))} + style={{ flex: 1, minWidth: 320 }} + /> +
+ +
+ 明细条数 + setNotifyForm((old) => ({ ...old, maxDetailRows: value ?? 0 }))} + style={{ width: 96 }} + /> + + 消息里最多列几条明细(0 = 只发汇总,每仓保底 1 条);钉钉文本上限 20000 字节,超了会自动精简 + +
+ +
+ @所有人 + setNotifyForm((old) => ({ ...old, atAll: checked }))} + checkedChildren="开" + unCheckedChildren="关" + /> + 打开后每轮超时都会 @所有人(0 单超时也会推一条平安消息) +
+ +
+ 页面链接 + setNotifyForm((old) => ({ ...old, pageUrl: e.target.value }))} + style={{ flex: 1, minWidth: 380 }} + /> +
+ + {notifyPreview ? ( +
+
+ + {notifyPreview.roundLabel || '预览'} + {' '}| 超时 {notifyPreview.rowCount} 单 + {' '}| 消息里列出 {notifyPreview.listedCount} 条 + {' '}| {Math.round((notifyPreview.bytes / 1024) * 10) / 10} KB + {notifyPreview.truncated ? '(超长已精简)' : ''} + + +
+
{notifyPreview.text || '(消息正文为空)'}
+
+ ) : null} + +
+ + 上次推送: + {notifyLastState.lastSentAt + ? <> {String(notifyLastState.lastSentAt).slice(0, 10)} {String(notifyLastState.lastSentAt).slice(11, 19)} + {' '}· {notifyLastState.lastRoundLabel || notifyLastState.lastRound || '—'} + {' '}· 超时 {notifyLastState.rowCount ?? 0} 单 + {' '}· 列出 {notifyLastState.listedCount ?? '—'} 条 + {notifyLastState.truncated ? '(超长已精简)' : ''} + : ' 还没有成功推送记录'} + + {notifyLastState.lastError ? ( +
+ 最近一次失败:{String(notifyLastState.lastError).slice(0, 300)} +
+ ) : null} +
+ + +
+
+
+ 说明:今天 7:00 前审核的订单 13:00 核验;截止 16:00 前审核的单 18:00 核验 (含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。 diff --git a/frontend/src/pages/WarehouseShipTimeliness/style.css b/frontend/src/pages/WarehouseShipTimeliness/style.css index 05eb597..2252413 100644 --- a/frontend/src/pages/WarehouseShipTimeliness/style.css +++ b/frontend/src/pages/WarehouseShipTimeliness/style.css @@ -253,3 +253,70 @@ gap: 2px 10px; } } + +/* ── 钉钉通知(两轮核验完成后各推一次)───────────────────────────── */ +.wst-notify__row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.wst-notify__label { + flex: 0 0 88px; + font-size: 13px; + color: var(--sycm-text-secondary); +} + +.wst-notify__last { + padding: 8px 10px; + border-radius: 6px; + background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02)); + font-size: 12px; +} + +.wst-notify__error { + margin-top: 4px; +} + +.wst-notify__help { + font-size: 12px; + line-height: 1.8; + color: var(--sycm-text-secondary); +} + +.wst-notify__help code { + padding: 0 4px; + border-radius: 3px; + background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04)); +} + +.wst-notify__preview { + padding: 10px 12px; + border: 1px solid var(--sycm-border, rgba(0, 0, 0, 0.08)); + border-radius: 8px; + background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02)); + font-size: 12px; + line-height: 1.8; +} + +.wst-notify__preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.wst-notify__preview pre { + margin: 8px 0 0; + padding: 8px 10px; + border-radius: 6px; + background: var(--sycm-bg-container, #fff); + white-space: pre-wrap; + word-break: break-all; + font-size: 12px; + line-height: 1.7; + /* 30 条明细大概 40 行,别把整个弹窗撑爆;内部滚动看全 */ + max-height: 280px; + overflow: auto; +} -- 2.47.2