feat(wst): 发货时效两轮核验完成后各推一次钉钉群

业务原话(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()` 一行即可停调度,新增文件无副作用。
This commit is contained in:
yanhui 2026-10-27 15:43:18 +08:00
parent a839d78d56
commit f766549b1a
8 changed files with 1905 additions and 4 deletions

View File

@ -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;

View File

@ -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

View File

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

View File

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

View File

@ -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);
});

View File

@ -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 }
);

View File

@ -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: (
<div className="wst-notify__help">
电脑版钉钉 → 打开目标群 → 右上角 <b>群设置</b> → <b>机器人</b> → <b>添加机器人</b> →
选 <b>自定义</b> → 起个名字(如「发货时效通知」)→ 复制 <b>Webhook 地址</b>。
<br />
地址形如 <code>https://oapi.dingtalk.com/robot/send?access_token=xxxx</code>,
整串(含 access_token)直接贴到下面。
</div>
),
},
{
key: 'security',
label: '② 安全设置:加签密钥(可选)',
children: (
<div className="wst-notify__help">
添加机器人时如果安全设置选了 <b>加签</b>,把以 <code>SEC</code> 开头的密钥填到「加签密钥」里。
<br />
如果选的是 <b>自定义关键词</b>,密钥留空即可 —— 但关键词必须包含 <b>超时</b>
(本模块消息抬头固定是「【仓储中心发货时效】」,正文里有「超时未发货 N 单」,天然命中)。
<br />
如果选的是 <b>IP 地址(段)</b>,需要把生产服务器 IP 加进白名单,密钥同样留空。
</div>
),
},
{
key: 'round',
label: '③ 为什么一天推两次、时间怎么定',
children: (
<div className="wst-notify__help">
业务口径:<b>13:00 核验 7 点前审核的超时单</b>、<b>18:00 核验 16 点前审核的单</b>,
各自<b>核验完成后</b>推一次,所以是两个独立时刻,互不影响。
<br />
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):聚水潭状态镜像要晚几分钟才覆盖到核验时刻,
整点推容易报「数据没覆盖核验点、超时数偏大」。想改成整点也可以,消息里会带这句提醒。
<br />
同一轮次同一天只推一次(服务重启不会重复轰炸);要重发点「立即推送」。
</div>
),
},
{
key: 'why',
label: '④ 为什么群里只有文字,没有 Excel 附件',
children: (
<div className="wst-notify__help">
钉钉的<b>自定义机器人 webhook</b> 只支持 text / markdown / link / actionCard,
<b>不支持发文件</b>。所以这里推的是<b>文本消息</b>:汇总数字 + 前 N 条明细 + 页面链接。
<br />
要全量明细或 Excel,点消息末尾的链接进页面查看 / 导出(口径与消息完全一致)。
</div>
),
},
];
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 (
<SycmPage className="wst-page">
<header className="wst-header">
@ -327,6 +552,7 @@ function WarehouseShipTimeliness() {
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s,点刷新重查)` : ''}
</Text>
) : null}
<Button icon={<BellOutlined />} onClick={openNotifySettings}>钉钉通知</Button>
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
</Space>
</div>
@ -505,7 +731,7 @@ function WarehouseShipTimeliness() {
onCancel={() => setHeldOpen(false)}
footer={null}
width={1080}
destroyOnClose
destroyOnHidden
>
<Alert
type="info"
@ -527,6 +753,183 @@ function WarehouseShipTimeliness() {
)}
</Modal>
<Modal
title="发货时效通知 · 两轮核验完成后各推一次到钉钉群"
open={notifyOpen}
onCancel={() => setNotifyOpen(false)}
width={760}
destroyOnHidden
maskClosable={false}
styles={{ body: { maxHeight: '62vh', overflowY: 'auto' } }}
footer={[
<Button key="close" onClick={() => setNotifyOpen(false)}>关闭</Button>,
<Button key="probe" onClick={() => handleRunNotify('probe')} loading={notifyRunning} disabled={notifySaving}>
测试通道
</Button>,
<Button key="preview" onClick={() => handleRunNotify('dry')} loading={notifyRunning} disabled={notifySaving}>
预览正文
</Button>,
<Button key="save" onClick={() => handleSaveNotify()} loading={notifySaving}>保存</Button>,
<Button key="run" type="primary" onClick={() => handleRunNotify('send')} loading={notifyRunning} disabled={notifySaving}>
立即推送到群
</Button>,
]}
>
<Spin spinning={notifyLoading}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type={notifyConfig?.ready ? 'success' : 'warning'}
showIcon
message={notifyConfig?.ready
? `通道已配好:13:00 轮 → ${notifyForm.noonSendTime} 推、18:00 轮 → ${notifyForm.afternoonSendTime} 推(0 单超时也推,用来确认任务在跑)`
: 'Webhook 地址还没配,填好保存后点「测试通道」验一下'}
description={notifyConfig?.ready ? null : (
<span>
缺:<b>{(notifyConfig?.missingFields || []).join(' / ') || '(尚未读取)'}</b>
</span>
)}
/>
<div className="wst-notify__row">
<span className="wst-notify__label">开启推送</span>
<Switch
checked={notifyForm.enabled}
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
checkedChildren="开"
unCheckedChildren="关"
/>
<Text type="secondary">关掉后定时任务不推,但「立即推送」仍可用(方便先试口径)</Text>
</div>
<div className="wst-notify__row">
<span className="wst-notify__label">13:00 轮</span>
<TimePicker
format="HH:mm"
value={notifyNoonValue}
allowClear={false}
minuteStep={5}
style={{ width: 96 }}
onChange={(value) => {
if (!value) return;
setNotifyForm((old) => ({ ...old, noonSendTime: value.format('HH:mm') }));
}}
/>
<Text type="secondary">核验「今天 07:00 前审核」的超时单,默认核验点 + 5 分钟</Text>
</div>
<div className="wst-notify__row">
<span className="wst-notify__label">18:00 轮</span>
<TimePicker
format="HH:mm"
value={notifyAfternoonValue}
allowClear={false}
minuteStep={5}
style={{ width: 96 }}
onChange={(value) => {
if (!value) return;
setNotifyForm((old) => ({ ...old, afternoonSendTime: value.format('HH:mm') }));
}}
/>
<Text type="secondary">核验「截止 16:00 前审核」的超时单(含历史未发),默认核验点 + 5 分钟</Text>
</div>
<div className="wst-notify__row">
<span className="wst-notify__label">Webhook</span>
<Input
value={notifyForm.webhookUrl}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxxx"
onChange={(e) => setNotifyForm((old) => ({ ...old, webhookUrl: e.target.value }))}
style={{ flex: 1, minWidth: 380 }}
/>
</div>
<div className="wst-notify__row">
<span className="wst-notify__label">加签密钥</span>
<Input.Password
value={notifyForm.webhookSecret}
placeholder={notifyConfig?.webhookSecretConfigured
? `已保存 ${notifyConfig.webhookSecretPreview || ''},留空表示不修改`
: 'SEC 开头;安全设置选「关键词 / IP」时留空'}
onChange={(e) => setNotifyForm((old) => ({ ...old, webhookSecret: e.target.value }))}
style={{ flex: 1, minWidth: 320 }}
/>
</div>
<div className="wst-notify__row">
<span className="wst-notify__label">明细条数</span>
<InputNumber
min={0}
max={notifyConfig?.maxDetailRowsLimit || 50}
precision={0}
value={notifyForm.maxDetailRows}
onChange={(value) => setNotifyForm((old) => ({ ...old, maxDetailRows: value ?? 0 }))}
style={{ width: 96 }}
/>
<Text type="secondary">
消息里最多列几条明细(0 = 只发汇总,每仓保底 1 条);钉钉文本上限 20000 字节,超了会自动精简
</Text>
</div>
<div className="wst-notify__row">
<span className="wst-notify__label">@所有人</span>
<Switch
checked={notifyForm.atAll}
onChange={(checked) => setNotifyForm((old) => ({ ...old, atAll: checked }))}
checkedChildren="开"
unCheckedChildren="关"
/>
<Text type="secondary">打开后每轮超时都会 @所有人(0 单超时也会推一条平安消息)</Text>
</div>
<div className="wst-notify__row">
<span className="wst-notify__label">页面链接</span>
<Input
value={notifyForm.pageUrl}
placeholder="消息末尾「查看全部」用的地址(留空用默认生产地址)"
onChange={(e) => setNotifyForm((old) => ({ ...old, pageUrl: e.target.value }))}
style={{ flex: 1, minWidth: 380 }}
/>
</div>
{notifyPreview ? (
<div className="wst-notify__preview">
<div className="wst-notify__preview-head">
<Text type="secondary">
{notifyPreview.roundLabel || '预览'}
{' '}| 超时 <b>{notifyPreview.rowCount}</b> 单
{' '}| 消息里列出 <b>{notifyPreview.listedCount}</b> 条
{' '}| {Math.round((notifyPreview.bytes / 1024) * 10) / 10} KB
{notifyPreview.truncated ? '(超长已精简)' : ''}
</Text>
<Button type="link" size="small" onClick={() => setNotifyPreview(null)}>收起</Button>
</div>
<pre>{notifyPreview.text || '(消息正文为空)'}</pre>
</div>
) : null}
<div className="wst-notify__last">
<Text type="secondary">
上次推送:
{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 ? '(超长已精简)' : ''}</>
: ' 还没有成功推送记录'}
</Text>
{notifyLastState.lastError ? (
<div className="wst-notify__error">
<Text type="danger">最近一次失败:{String(notifyLastState.lastError).slice(0, 300)}</Text>
</div>
) : null}
</div>
<Collapse size="small" items={NOTIFY_HELP_ITEMS} />
</Space>
</Spin>
</Modal>
<Text type="secondary" className="wst-foot">
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。

View File

@ -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;
}