feat(wle): 仓储转运异常每天定点推文本到钉钉群

在「仓储中心物流转运异常」页新增「异常通知」:每天 09:10(可改)把当天核验批次
的异常情况以**文本消息**推到钉钉群,0 条也推(用来确认任务没挂)。

为什么是 text 而不是 Excel 附件:钉钉群自定义机器人 webhook 只支持
text/markdown/link/actionCard,发文件必须走企业内部应用(AppKey/AppSecret/
robotCode/openConversationId 四项凭据 + 应用权限 + 机器人进群)。业务确认
「群里看文本就够,要明细点链接去页面导出」,于是走最轻的 webhook。

后端
- 新增 services/dingtalkRobotWebhookSender.js:POST /robot/send + msgtype=text
  · 加签 urlEncode(base64(HmacSHA256(secret, "timestamp\nsecret"))),timestamp 毫秒
  · text.content 按 UTF-8 **字节**截断到 20000(钉钉硬上限),不切半个汉字
  · webhook 严格匹配 ^/robot/send$ 且必须有 access_token;errcode/errmsg 原样抛
- 新增 services/warehouseLogisticsExceptionNotifySettings.js:
  · webhookUrl 存在 system_config(完整回显,业务要核对);加签密钥走
    resilientSecretStore,接口只回 configured + 掩码,留空=沿用已存
  · sendTime 非法退回 09:10;maxDetailRows 0~50 默认 30
  · 状态 lastSentDate(按推送日去重)等存 system_config
- 新增 jobs/warehouseLogisticsExceptionNotifyJob.js:
  · buildNotifyText:抬头【仓储物流转运异常】→ 发货/包裹/异常/异常率 → 分类型
    → 剔除数 → 按仓(只列有异常的仓)→ 明细前 N 条 → 页面链接(带 date)
  · 明细行「仓|内部单号|快递|异常类型|发货时间|待处理」,字段内换行压平
  · 超长从明细末尾逐条砍并标记 truncated(truncated 只表示被字节上限砍过,
    不表示「按配置只列前 N 条」)
  · 每分钟 tick 比对北京时间 HH:mm == 配置 sendTime,改时间不用重启
- api:GET/PUT /notify/settings、GET /notify/state、POST /notify/run
  (dryRun 预览不校验凭据;probe 只发一句测试文本、不查业务数据)

前端
- 「导出」右侧新增「异常通知」(铃铛)弹窗:开关、推送时间、Webhook、加签密钥、
  明细条数、@所有人、页面链接、上次推送结果、三段帮助说明
- 「测试通道」「预览正文」「保存」「立即推送到群」
- 预览改成弹窗内受控区块:本项目跑 React 19 + antd v5,Modal.info 静态方法
  拿不到 context 会**静默不渲染**(只留一行 warning,看着像后端没返回)

同步与兼容
- 新配置首次读取为空 → ready=false,页面提示「Webhook 地址还没配」,不影响页面其它功能
- 定时任务只在 enabled=true 时生效;本地 ENABLE_BACKGROUND_JOBS=false 不跑,用页面按钮验

验证
- backend/test/warehouseLogisticsExceptionNotify.test.js 15/15:正文结构与数字、
  0 条平安消息、条数上限、字节精简、Webhook 校验、加签 crypto 手算对齐、errcode 透传
- 后端全量 465 项 / 463 过(2 项既有环境失败:Windows 权限位、负缓存需隧道故障态)
- 前端 eslint 0、build 成功
- 真数据干跑:144 条异常 / 列 30 条 / 3768 字节 / 20s,按仓包裹数合计 144 对得上
- playwright 真渲染:弹窗字段 Webhook/加签密钥/明细条数/@所有人/页面链接,
  旧字段 AppKey/AppSecret/robotCode/群会话 ID 零残留;预览内联渲染出正文

风险与回退
- 钉钉侧失败只会记 lastState.lastError 并在页面展示,不影响页面取数
- 回退:删除 index.js 里的 startWarehouseLogisticsExceptionNotifyScheduler 注册即可停推送
This commit is contained in:
yanhui 2026-10-22 11:00:10 +08:00
parent b30c4e949c
commit bdc2cf9fd6
9 changed files with 1912 additions and 2 deletions

View File

@ -8,6 +8,14 @@
* date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天)
* limit 明细条数上限(默认 500,最大 2000)
* force 1 = 绕过 5 分钟结果缓存
* GET /api/warehouse-logistics-exception/export
* 同 /list 口径的不分页明细(导出用,上限 5000)
*
* 每天 09:10 推 Excel 到钉钉群(2026-10-22 新增):
* GET /api/warehouse-logistics-exception/notify/settings 读配置(AppSecret 只回掩码)
* PUT /api/warehouse-logistics-exception/notify/settings 保存配置(AppSecret 留空=不变)
* POST /api/warehouse-logistics-exception/notify/run 立即推送一次(force / dryRun)
* GET /api/warehouse-logistics-exception/notify/state 上次推送结果
*
* 挂载见 src/index.js:`/api/warehouse-logistics-exception` + authMiddleware
*/
@ -20,6 +28,16 @@ const {
const {
CENTERS, EXCEPTION_TYPES, LOOKBACK_DAYS, VERIFY_HOUR,
} = require('../services/warehouseLogisticsExceptionRegistry');
const {
getWarehouseLogisticsExceptionNotifyAdminConfig,
loadWarehouseLogisticsExceptionNotifyState,
missingFields,
saveWarehouseLogisticsExceptionNotifyConfig,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
} = require('../services/warehouseLogisticsExceptionNotifySettings');
const {
pushWarehouseLogisticsExceptionNotify,
} = require('../jobs/warehouseLogisticsExceptionNotifyJob');
const router = express.Router();
@ -67,4 +85,89 @@ router.get('/meta', (_req, res) => {
});
});
// ── 每天推送到钉钉群 ────────────────────────────────────────────────────────
// 读配置:AppSecret 只回掩码 + 是否已配置,明文永不出后端
router.get('/notify/settings', async (_req, res) => {
try {
const [config, state] = await Promise.all([
getWarehouseLogisticsExceptionNotifyAdminConfig(),
loadWarehouseLogisticsExceptionNotifyState(),
]);
res.json({ success: true, data: { ...config, lastState: state || {} } });
} catch (error) {
sendError(res, error, '读取异常通知配置失败');
}
});
// 保存配置;AppSecret 留空表示「沿用已存的」,不必每次重填
router.put('/notify/settings', async (req, res) => {
try {
const data = await saveWarehouseLogisticsExceptionNotifyConfig(req.body || {});
res.json({ success: true, data });
} catch (error) {
const status = Number(error?.status) || 400;
console.error('[WarehouseLogisticsException] 保存通知配置失败:', error?.message);
res.status(status).json({ success: false, error: error.message || '保存异常通知配置失败' });
}
});
router.get('/notify/state', async (_req, res) => {
try {
const state = await loadWarehouseLogisticsExceptionNotifyState();
res.json({ success: true, data: state || {} });
} catch (error) {
sendError(res, error, '读取推送状态失败');
}
});
/**
* 立即推送一次(页面「立即推送」/「预览」/「测试通道」)。
* body:
* dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径)
* probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通)
* force true = 忽略「今天已经推过」(默认手动触发就是 true,除非显式给 false)
* date 指定核验日
*/
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 getWarehouseLogisticsExceptionNotifyRuntimeConfig({ 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 pushWarehouseLogisticsExceptionNotify({
dryRun,
probe,
force,
date: body.date || undefined,
});
// 查完这一轮记得让页面上的状态同步(lastState 由前端重新拉 settings 获取)
res.json({ success: true, data: result });
} catch (error) {
const status = Number(error?.status) || 500;
console.error('[WarehouseLogisticsException] 推送失败:', {
message: error?.message,
code: error?.code,
dingtalk: error?.dingtalk,
});
res.status(status).json({
success: false,
error: error.message || '推送到钉钉群失败',
data: { code: error?.code ?? null },
});
}
});
module.exports = router;

View File

@ -68,6 +68,9 @@ const {
const {
startMkhReplenishAlertScheduler,
} = require('./jobs/mkhReplenishAlertJob');
const {
startWarehouseLogisticsExceptionNotifyScheduler,
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
const { ensureLocalSchema } = require('./services/localSchema');
const {
@ -791,6 +794,7 @@ async function startServer(options = {}) {
startGoodsFirstSeenScheduler();
startAlibaba1688ImageSearchExpiryScheduler();
startMkhReplenishAlertScheduler();
startWarehouseLogisticsExceptionNotifyScheduler();
startDailyInventorySnapshotScheduler();
// Keep the first selection-pool page request from paying the remote schema

View File

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

View File

@ -0,0 +1,210 @@
'use strict';
/**
* 钉钉「群自定义机器人」webhook 发文本通道(2026-10-22 新增)
* ────────────────────────────────────────────────────────────────────────────
* 为什么不用企业内部应用发文件?
* 群机器人 webhook **只能发** text / markdown / link / actionCard / feedCard,
* 发 .xlsx 附件必须走企业内部应用(AppKey/AppSecret/robotCode/openConversationId)。
* 业务最终选择「群里直接看文本」,于是回到最简单的一条路:
* 只要一个 webhook 地址(加签可选),不用申请应用、不用管 token 过期。
*
* 请求形状(钉钉官方「自定义机器人接入」):
* POST https://oapi.dingtalk.com/robot/send?access_token=xxx&timestamp=xxx&sign=xxx
* { "msgtype": "text", "text": { "content": "..." }, "at": { "isAtAll": false } }
* → { "errcode": 0, "errmsg": "ok" }
*
* 三个必须知道的限制(踩过就知道疼):
* ① text.content **上限 20000 字节**(中文 UTF-8 是 3 字节/字)→ 超了钉钉直接 400,
* 所以这里按**字节**截断(不是按字符),并返回是否被截断。
* ② 加了「自定义关键词」安全设置时,content 里必须含关键词(常用「异常」「提醒」),
* 否则 errcode 310000。这个错要让业务自己看得懂,所以原样透出 errmsg。
* ③ 加签密钥(SEC 开头)算出来的 sign 必须 **urlEncode**,且 timestamp 是**毫秒**。
* 顺序错了就是 310000 sign not match,很难看出是编码问题。
*
* 失败一律抛错并带上钉钉原始 errcode/errmsg —— 业务排错全靠这个码。
*/
const crypto = require('crypto');
const OAPI_BASE = process.env.DINGTALK_OAPI_BASE || 'https://oapi.dingtalk.com';
const REQUEST_TIMEOUT_MS = Math.max(Number(process.env.DINGTALK_WEBHOOK_TIMEOUT_MS) || 20000, 3000);
// 钉钉 text 消息 content 的硬上限(字节)。留 200 字节余量给自己追加的尾巴
const MAX_CONTENT_BYTES = 20000;
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 200;
function normalizeText(value, maxLength = 512) {
return String(value ?? '').trim().slice(0, maxLength);
}
/** 按 UTF-8 **字节**截断,且不把多字节字符切一半 */
function truncateBytes(text, maxBytes = SAFE_CONTENT_BYTES) {
const source = String(text ?? '');
if (Buffer.byteLength(source, 'utf8') <= maxBytes) return { text: source, truncated: false };
const chars = Array.from(source);
let bytes = 0;
let out = '';
for (const char of chars) {
const size = Buffer.byteLength(char, 'utf8');
if (bytes + size > maxBytes) break;
bytes += size;
out += char;
}
return { text: out, truncated: true };
}
function buildDingTalkError(label, payload, httpStatus) {
const code = payload?.errcode ?? payload?.code ?? httpStatus ?? null;
const message = payload?.errmsg || payload?.message || `${label}失败`;
const error = new Error(`${label}失败(${code}):${message}`);
error.code = code;
error.httpStatus = httpStatus || null;
error.dingtalk = payload || null;
return error;
}
/**
* 校验 webhook 地址。放宽 host(支持自建代理 / 测试 stub),但必须 https 且是 robot/send。
* 返回去掉首尾空白后的地址;不合法抛 400(页面上直接提示,不让它跑到定时任务里才炸)。
*/
function normalizeWebhookUrl(value) {
const text = normalizeText(value, 1024);
if (!text) {
throw Object.assign(new Error('请填写钉钉群机器人的 Webhook 地址'), { status: 400, code: 'WEBHOOK_REQUIRED' });
}
let url;
try {
url = new URL(text);
} catch (_error) {
throw Object.assign(new Error('Webhook 地址不是合法 URL'), { status: 400, code: 'WEBHOOK_INVALID' });
}
if (!/^https?:$/.test(url.protocol)) {
throw Object.assign(new Error('Webhook 地址必须以 http(s):// 开头'), { status: 400, code: 'WEBHOOK_INVALID' });
}
// 严格匹配 `/robot/send`(不要用「以 /robot/send 结尾」—— 那样
// `topapi/robot/send` 这种企业内部应用地址也会被放过去,最后报一个难懂的 404)
if (!/^\/robot\/send\/?$/.test(url.pathname)) {
throw Object.assign(
new Error('这不像群机器人 Webhook(正常形如 https://oapi.dingtalk.com/robot/send?access_token=xxx)'),
{ status: 400, code: 'WEBHOOK_INVALID' }
);
}
if (!url.searchParams.get('access_token')) {
throw Object.assign(
new Error('Webhook 地址缺少 access_token 参数,请从钉钉群机器人设置里完整复制'),
{ status: 400, code: 'WEBHOOK_INVALID' }
);
}
return url.toString();
}
/**
* 加签:sign = urlEncode(base64(HmacSHA256(secret, `${timestamp}\n${secret}`)))
* ⚠️ HMAC 的 key 是 secret、消息是 `timestamp\nsecret`(顺序反过来就是 310000)
*/
function buildSignedUrl(webhookUrl, secret, timestampMs = Date.now()) {
const timestamp = String(Number(timestampMs) || Date.now());
const timestamped = new URL(webhookUrl);
timestamped.searchParams.set('timestamp', timestamp);
const text = normalizeText(secret, 256);
if (!text) return timestamped.toString();
const sign = crypto
.createHmac('sha256', text)
.update(`${timestamp}\n${text}`, 'utf8')
.digest('base64');
timestamped.searchParams.set('sign', sign);
return timestamped.toString();
}
async function requestJson(label, url, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
const raw = await response.text();
let payload = null;
try {
payload = raw ? JSON.parse(raw) : {};
} catch (_error) {
payload = { errmsg: raw.slice(0, 500) };
}
if (!response.ok) throw buildDingTalkError(label, payload, response.status);
if (payload && payload.errcode !== undefined && Number(payload.errcode) !== 0) {
throw buildDingTalkError(label, payload, response.status);
}
return payload || {};
} catch (error) {
if (error.name === 'AbortError') {
throw Object.assign(new Error(`${label}超时(${REQUEST_TIMEOUT_MS}ms)`), { code: 'TIMEOUT' });
}
throw error;
} finally {
clearTimeout(timer);
}
}
/**
* 发一条文本消息到群。
* @param {object} params
* @param {string} params.webhookUrl 群机器人 Webhook(含 access_token)
* @param {string} [params.secret] 加签密钥(安全设置选「加签」时填;选关键词/IP 时留空)
* @param {string} params.content 纯文本(支持 \n 换行)
* @param {boolean} [params.atAll] 是否 @所有人
* @param {string[]} [params.atMobiles]
* @returns {Promise<{delivered:boolean,truncated:boolean,bytes:number,content:string}>}
*/
async function sendTextToWebhook({
webhookUrl, secret, content, atAll = false, atMobiles = [],
}) {
const base = normalizeWebhookUrl(webhookUrl);
const raw = String(content ?? '');
if (!raw.trim()) {
throw Object.assign(new Error('待发送的消息内容为空'), { code: 'EMPTY_CONTENT' });
}
const limited = truncateBytes(raw);
const url = buildSignedUrl(base, secret);
const mobiles = (Array.isArray(atMobiles) ? atMobiles : [])
.map((item) => normalizeText(item, 32))
.filter((item) => /^\d{6,20}$/.test(item));
const payload = await requestJson('发送钉钉群消息', url, {
method: 'POST',
headers: { 'Content-Type': 'application/json;charset=utf-8' },
body: JSON.stringify({
msgtype: 'text',
text: { content: limited.text },
at: { isAtAll: atAll === true, atMobiles: mobiles },
}),
});
return {
delivered: true,
truncated: limited.truncated,
bytes: Buffer.byteLength(limited.text, 'utf8'),
content: limited.text,
errmsg: normalizeText(payload.errmsg, 200) || 'ok',
};
}
/**
* 「测试推送」用一句短文本探活:只验 webhook(+加签)能不能通。
* 不读取业务数据 —— 测试时不该因为数据慢而误判成通道坏。
*/
async function sendProbeText({ webhookUrl, secret, text }) {
const content = normalizeText(text, 500)
|| '【仓储中心物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。';
return sendTextToWebhook({ webhookUrl, secret, content });
}
module.exports = {
MAX_CONTENT_BYTES,
OAPI_BASE,
buildSignedUrl,
normalizeWebhookUrl,
sendProbeText,
sendTextToWebhook,
truncateBytes,
};

View File

@ -0,0 +1,318 @@
'use strict';
/**
* 「仓储中心物流转运异常」每天定点推文本到钉钉群 —— 配置与状态存取
* ────────────────────────────────────────────────────────────────────────────
* 配置分两层存(与统一通知中心同一套做法,见 yydNotificationCenterSettings):
* · 普通字段 → 本地库 `system_config`,key = warehouse_logistics_exception_notify_config
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
*
* 通道是「钉钉群自定义机器人 webhook」(2026-10-22 业务定:不发 .xlsx 附件,
* 群里直接看文本),所以只有两个凭据字段:
* · webhookUrl 群机器人地址(含 access_token;完整回显,业务要能核对)
* · webhookSecret 加签密钥(SEC 开头,只回掩码;安全设置选「关键词/IP」时留空)
*
* 推送时刻用页面上的 HH:mm(默认 09:10,核验 09:00 之后),**按北京时间解释**:
* 生产容器是 UTC,绝不能拿容器本地时间比。
*/
const database = require('../config/database');
const {
getResilientSecret,
saveResilientSecret,
} = require('./resilientSecretStore');
const CONFIG_KEY = 'warehouse_logistics_exception_notify_config';
const SECRET_KEY = 'warehouse_logistics_exception_notify_webhook_secret';
const STATE_KEY = 'warehouse_logistics_exception_notify_state';
const DEFAULT_SEND_TIME = '09:10';
// 明细条数:text 消息上限 20000 字节,30 条中文明细约 9~12KB,留足余量
const DEFAULT_MAX_DETAIL_ROWS = 30;
const MAX_DETAIL_ROWS_LIMIT = 50;
// 页面地址(消息里给「查看全部」链接)。生产是带 8443 的域名,别写成裸域名
const DEFAULT_PAGE_URL = process.env.WLE_PAGE_URL
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-logistics-exception';
const CACHE_TTL_MS = 30 * 1000;
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);非法输入退回默认 09:10,不抛错(避免把定时任务配挂) */
function normalizeSendTime(value) {
const text = normalizeText(value, 5);
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
return DEFAULT_SEND_TIME;
}
/** 明细条数: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;
}
function splitSendTime(sendTime) {
const [hour, minute] = normalizeSendTime(sendTime).split(':').map((part) => Number(part));
return { hour, minute };
}
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('[WleNotify] 读取推送配置失败:', error.message);
return {};
}
}
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
async function getWarehouseLogisticsExceptionNotifyRuntimeConfig({ 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.WLE_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
});
cachedConfig = {
enabled: normalizeBoolean(stored.enabled, false),
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|| normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK, 1024),
webhookSecret: secret.value,
webhookSecretSource: secret.source,
sendTime: normalizeSendTime(stored.sendTime || stored.send_time),
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 isWarehouseLogisticsExceptionNotifyReady(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',
sendTime: normalizeSendTime(config.sendTime),
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
atAll: config.atAll === true,
pageUrl: normalizePageUrl(config.pageUrl),
ready: isWarehouseLogisticsExceptionNotifyReady(config),
missingFields: missingFields(config),
source: config.source || 'default',
};
}
async function getWarehouseLogisticsExceptionNotifyAdminConfig() {
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
return toAdminConfig(config);
}
/**
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
*/
async function saveWarehouseLogisticsExceptionNotifyConfig(input = {}) {
const current = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ 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),
sendTime: input.sendTime === undefined && input.send_time === undefined
? normalizeSendTime(current.sendTime)
: normalizeSendTime(input.sendTime ?? input.send_time),
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,
sendTime: config.sendTime,
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);
}
/** 上次推送结果(页面展示 + 定时任务用来判断「今天是不是已经推过」) */
async function loadWarehouseLogisticsExceptionNotifyState() {
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, {});
return state && typeof state === 'object' ? state : {};
} catch (error) {
console.warn('[WleNotify] 读取推送状态失败:', error.message);
return {};
}
}
async function saveWarehouseLogisticsExceptionNotifyState(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('[WleNotify] 保存推送状态失败:', error.message);
}
}
function clearNotifyConfigCache() {
cachedConfig = null;
cachedAt = 0;
}
module.exports = {
CONFIG_KEY,
DEFAULT_MAX_DETAIL_ROWS,
DEFAULT_PAGE_URL,
DEFAULT_SEND_TIME,
MAX_DETAIL_ROWS_LIMIT,
SECRET_KEY,
STATE_KEY,
clearNotifyConfigCache,
getWarehouseLogisticsExceptionNotifyAdminConfig,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
isWarehouseLogisticsExceptionNotifyReady,
loadWarehouseLogisticsExceptionNotifyState,
maskSecret,
missingFields,
normalizeMaxDetailRows,
normalizePageUrl,
normalizeSendTime,
saveWarehouseLogisticsExceptionNotifyConfig,
saveWarehouseLogisticsExceptionNotifyState,
splitSendTime,
toAdminConfig,
};

View File

@ -0,0 +1,394 @@
'use strict';
/**
* 「仓储中心物流转运异常」每天推文本到钉钉群 —— 正文组装与通道回归测试
* ────────────────────────────────────────────────────────────────────────────
* 这里只测四件容易悄悄坏掉的事:
* ① 群消息正文的数字与明细(业务照着这条消息干活,数字错了没人能发现);
* ② 0 条 / 明细条数上限 / 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
* ③ webhook 地址与加签(sign 算错就是 310000,肉眼看不出来);
* ④ 钉钉报错必须原样透出 errcode/errmsg(业务排错全靠这个码)。
* 不测数据库与真实网络:settings / job 的 DB 分支在接口层验证。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
const job = require('../src/jobs/warehouseLogisticsExceptionNotifyJob');
const sender = require('../src/services/dingtalkRobotWebhookSender');
const settings = require('../src/services/warehouseLogisticsExceptionNotifySettings');
function sampleData(overrides = {}) {
return {
batch: {
label: '10-22 09:00 核验 · 10-21 发货',
verifyDate: '2026-10-22',
shipDate: '2026-10-21',
deadline: '2026-10-22 09:00:00',
},
summary: {
shippedOrders: 9886,
packageCount: 9886,
exceptionOrders: 143,
exceptionPackages: 144,
exceptionRate: 1.4,
centerCount: 9,
byType: { PICKED_NO_TRANSIT: 137, NOT_PICKED: 7 },
},
centers: [
{ key: 'sn', name: '睢宁仓', shippedOrders: 1200, exceptionOrders: 1, exceptionRate: 0.1 },
{ key: 'cq', name: '崔桥工厂仓', shippedOrders: 3000, exceptionOrders: 2, exceptionRate: 0.1 },
{ key: 'hl', name: '横林二仓', shippedOrders: 800, exceptionOrders: 0, exceptionRate: 0 },
],
types: [
{ key: 'PICKED_NO_TRANSIT', label: '已揽收未中转', desc: '揽收后没有下一步中转记录' },
{ key: 'NOT_PICKED', label: '未揽收', desc: '快递还没来揽' },
],
exclusions: {
carriers: ['唯品顺丰', '顺丰', '京喜京东'],
conditionalCarriers: [{ keyword: '睢宁中通', keepCenters: ['睢宁仓'] }],
orderStatusKeywords: ['取消', '作废'],
orderLabelKeywords: ['发货后取消'],
orderTypeLabels: ['刷单', '转空包'],
carrierPackages: 12,
voidedPackages: 3,
nonRealPackages: 5,
},
rows: [
{
centerKey: 'cq',
warehouseName: '崔桥工厂仓',
oId: '9845001',
soId: '3318',
shopName: '京东自营',
carrier: '京东快递',
trackingNo: 'JD1',
logisticStatus: '已揽收',
exceptionLabel: '已揽收未中转',
exceptionDesc: '揽收后没有下一步中转记录',
sendTime: '2026-10-21 16:00:00',
lastLogisticTime: '2026-10-21 21:00:00',
pendingHours: 17,
lastLogisticInfo: '已揽收',
},
{
centerKey: 'sn',
warehouseName: '睢宁仓',
oId: '9844248',
soId: '3316',
shopName: '天猫旗舰店',
carrier: '睢宁德邦',
trackingNo: '',
logisticStatus: '',
exceptionLabel: '未揽收',
exceptionDesc: '快递还没来揽',
sendTime: '2026-10-21 14:22:10',
lastLogisticTime: '',
pendingHours: null,
lastLogisticInfo: '',
},
],
...overrides,
};
}
test('群消息正文:抬头带「异常」关键词、数字与分类型、按仓只列有异常的仓', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30, pageUrl: 'https://ck.yydd.ai:8443/p' });
const text = built.text;
// 钉钉「自定义关键词」安全设置一般配「异常」,抬头必须含这两个字
assert.match(text, /【仓储物流转运异常】/);
assert.match(text, /10-22 09:00 核验 · 10-21 发货/);
assert.match(text, /已发货 9886 单 \/ 9886 个包裹,异常 144 个包裹(1\.4%)/);
// 分类型按数量降序,且必须是可读标签(不是 PICKED_NO_TRANSIT 这种 key)
assert.match(text, /类型:已揽收未中转 137 · 未揽收 7/);
// 剔除数量 = 12 + 3 + 5
assert.match(text, /已剔除 20 个包裹/);
// 按仓只列有异常的:睢宁 1、崔桥 1;横林二仓 0 条不能出现
assert.match(text, /按仓(异常包裹):/);
assert.match(text, /睢宁仓 1 个(发货 1200 单)/);
assert.match(text, /崔桥工厂仓 1 个(发货 3000 单)/);
assert.ok(!text.includes('横林二仓'), '没有异常的仓不该占行');
// 页码链接带核验日
assert.match(text, /查看全部 \/ 导出 Excel:https:\/\/ck\.yydd\.ai:8443\/p\?date=2026-10-22/);
assert.equal(built.rowCount, 144);
assert.equal(built.listedCount, 2);
assert.equal(built.truncated, false);
});
test('明细行:仓|内部单号|快递|异常类型|发货时间|待处理,字段里换行要压平', () => {
const line = job.buildDetailLine({
warehouseName: '崔桥工厂仓',
oId: '9845001',
carrier: '京东快递',
exceptionLabel: '已揽收未中转',
sendTime: '2026-10-21 16:00:00',
pendingHours: 17.25,
lastLogisticInfo: '第一行\n第二行',
}, 3);
assert.equal(line, '3. 崔桥工厂仓|9845001|京东快递|已揽收未中转|10-21 16:00|17.3h');
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
// 空值兜底:不能让消息里出现「undefined|null」
assert.equal(
job.buildDetailLine({}, 1),
'1. 未知仓|无单号|未知快递|异常|—|—'
);
});
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
const data = sampleData({
rows: [],
summary: {
...sampleData().summary,
exceptionOrders: 0,
exceptionPackages: 0,
exceptionRate: 0,
byType: {},
centerCount: 0,
},
exclusions: {},
});
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
assert.match(built.text, /本批无转运异常,全部正常/);
assert.match(built.text, /异常 0 个包裹(/);
assert.ok(!built.text.includes('明细前'), '0 条时不该出现明细标题');
assert.equal(built.rowCount, 0);
assert.equal(built.listedCount, 0);
});
test('明细条数受配置控制:0 = 只发汇总,超量只列前 N 条并提示剩余', () => {
const zero = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
assert.ok(!/明细前 \d+ 条/.test(zero.text), 'maxDetailRows=0 时不要明细块');
assert.match(zero.text, /明细共 144 条,消息里不放,请点下面链接查看/);
const one = job.buildNotifyText(sampleData(), { maxDetailRows: 1 });
assert.equal(one.listedCount, 1);
assert.ok(one.text.includes('1. 崔桥工厂仓'), '只列第一条');
assert.ok(!one.text.includes('2. 睢宁仓'), '第二条应被截掉');
assert.match(one.text, /其余 143 条见页面/);
});
test('明细太多时按字节精简:不超 20000 字节上限,且不切断半个汉字', () => {
// 造 60 条很长的明细(仓名 144 字 ≈ 432 字节/行),必然超过 text 上限
const longName = '崔桥工厂半成品包装项目仓'.repeat(12);
const rows = Array.from({ length: 60 }, (_item, index) => ({
centerKey: 'cq',
warehouseName: longName,
oId: `9845${String(index).padStart(3, '0')}`,
carrier: '中通快递',
exceptionLabel: '已揽收未中转',
sendTime: '2026-10-21 16:00:00',
pendingHours: 17,
}));
const data = sampleData({
rows,
summary: { ...sampleData().summary, exceptionPackages: rows.length, exceptionOrders: rows.length },
});
const built = job.buildNotifyText(data, { maxDetailRows: 50 });
assert.ok(built.bytes <= sender.MAX_CONTENT_BYTES, `实得 ${built.bytes} 字节,超了上限`);
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, '应留出安全余量');
assert.equal(built.truncated, true, '应标记为已精简');
assert.ok(built.listedCount < 50, '应自动少列几条');
assert.ok(!built.text.includes('\uFFFD'), '不能出现被截断产生的乱码');
// 削掉明细后仍保留抬头和链接
assert.match(built.text, /【仓储物流转运异常】/);
assert.match(built.text, /查看全部/);
});
test('按字节截断工具:中文按 3 字节算,不切半个字', () => {
assert.deepEqual(sender.truncateBytes('abc', 10), { text: 'abc', truncated: false });
// 「中」= 3 字节:10 字节最多放 3 个字(9 字节)
const cut = sender.truncateBytes('中文字符', 10);
assert.equal(cut.text, '中文字');
assert.equal(cut.truncated, true);
assert.ok(Buffer.byteLength(cut.text, 'utf8') <= 10);
});
test('时区:容器是 UTC,推送时刻仍按北京时间判(09:10 不能变成 01:10)', () => {
// 2026-10-22T01:10Z = 北京时间 09:10
assert.equal(job.chinaHourMinute(new Date('2026-10-22T01:10:00Z')), '09:10');
assert.equal(job.chinaDateKey(new Date('2026-10-22T01:10:00Z')), '2026-10-22');
// 北京时间 00:30(UTC 前一天 16:30)→ 日期必须是新的一天
assert.equal(job.chinaDateKey(new Date('2026-10-21T16:30:00Z')), '2026-10-22');
assert.equal(job.chinaHourMinute(new Date('2026-10-21T16:30:00Z')), '00:30');
});
test('推送时间 / 明细条数:非法输入一律退回默认值,不能把定时任务配挂', () => {
assert.equal(settings.normalizeSendTime('08:05'), '08:05');
assert.equal(settings.normalizeSendTime('9:10'), '09:10');
assert.equal(settings.normalizeSendTime('25:00'), '09:10');
assert.equal(settings.normalizeSendTime(''), '09:10');
assert.equal(settings.normalizeSendTime(undefined), '09:10');
assert.equal(settings.normalizeMaxDetailRows(undefined), 30);
assert.equal(settings.normalizeMaxDetailRows(-5), 0);
assert.equal(settings.normalizeMaxDetailRows(999), 50);
assert.equal(settings.normalizeMaxDetailRows('12'), 12);
assert.equal(settings.normalizeMaxDetailRows('abc'), 30);
});
test('配置校验:只要有 Webhook 就算配好;加签密钥只回掩码', () => {
assert.deepEqual(settings.missingFields({ webhookUrl: '' }), ['Webhook 地址']);
assert.deepEqual(settings.missingFields({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }), []);
assert.equal(settings.isWarehouseLogisticsExceptionNotifyReady({}), false);
assert.equal(
settings.isWarehouseLogisticsExceptionNotifyReady({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }),
true,
'安全设置用「关键词」时不需要加签密钥'
);
const admin = settings.toAdminConfig({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t',
webhookSecret: 'SEC0123456789abcdef',
enabled: true,
});
assert.equal(admin.webhookSecretConfigured, true);
assert.ok(!JSON.stringify(admin).includes('SEC0123456789abcdef'), '明文加签密钥不能出现在返回体里');
assert.match(admin.webhookSecretPreview, /\*+/);
// Webhook 要完整回显:业务需要核对 access_token
assert.match(admin.webhookUrl, /access_token=t/);
});
test('Webhook 地址校验:必须是 robot/send 且带 access_token', () => {
const ok = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
assert.equal(sender.normalizeWebhookUrl(ok), ok);
assert.throws(() => sender.normalizeWebhookUrl(''), /请填写/);
assert.throws(() => sender.normalizeWebhookUrl('oapi.dingtalk.com/robot/send'), /不是合法 URL/);
// 常见的复制错误:贴成了企业内部应用的地址 / 少复制了路径
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/robot/send'), /缺少 access_token/);
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/topapi/robot/send?access_token=a'), /不像群机器人/);
});
test('加签:sign = urlEncode(base64(HmacSHA256(secret, `${ts}\\n${secret}`)))', () => {
const webhook = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
const secret = 'SECtestsecret';
const ts = 1700000000000;
const signed = new URL(sender.buildSignedUrl(webhook, secret, ts));
assert.equal(signed.searchParams.get('timestamp'), String(ts));
assert.equal(signed.searchParams.get('access_token'), 'abc123', '原参数要保留');
// 手工算一遍(顺序写错就是 310000,这条断言就是防这个)
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}\n${secret}`, 'utf8')
.digest('base64');
// searchParams 会自动 decode,所以这里拿到的是原始 base64(含 + / =)
assert.equal(signed.searchParams.get('sign'), expected);
// 没配密钥(安全设置用关键词/IP):只追加 timestamp,不带 sign
const plain = new URL(sender.buildSignedUrl(webhook, '', ts));
assert.equal(plain.searchParams.get('sign'), null);
assert.equal(plain.searchParams.get('timestamp'), String(ts));
});
// ── 钉钉通道:stub fetch 验请求形状与错误透传 ────────────────────────────────
function stubFetch(handler) {
const calls = [];
const original = global.fetch;
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), options });
const body = handler.body === undefined ? {} : handler.body;
return new Response(JSON.stringify(body), { status: handler.status || 200 });
};
return {
calls,
restore: () => { global.fetch = original; },
};
}
test('发文本:msgtype=text,加签参数拼在 URL 上,@所有人开关透传', async () => {
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
try {
const result = await sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=abc123',
secret: 'SECtestsecret',
content: '【仓储物流转运异常】测试',
atAll: true,
});
assert.equal(result.delivered, true);
assert.equal(result.errmsg, 'ok');
const call = stub.calls[0];
assert.equal(call.options.method, 'POST');
assert.match(call.options.headers['Content-Type'], /application\/json/);
// 加签后 URL 必须带 timestamp + sign
const url = new URL(call.url);
assert.ok(url.searchParams.get('timestamp'), '缺少 timestamp');
assert.ok(url.searchParams.get('sign'), '缺少 sign');
const body = JSON.parse(call.options.body);
assert.equal(body.msgtype, 'text');
assert.equal(body.text.content, '【仓储物流转运异常】测试');
assert.equal(body.at.isAtAll, true);
assert.deepEqual(body.at.atMobiles, []);
} finally {
stub.restore();
}
});
test('发文本:空内容不发请求(避免钉钉 400 把错误码埋掉)', async () => {
let touched = 0;
const original = global.fetch;
global.fetch = async () => { touched += 1; throw new Error('不应该发请求'); };
try {
await assert.rejects(
() => sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
content: ' ',
}),
/内容为空/
);
assert.equal(touched, 0);
} finally {
global.fetch = original;
}
});
test('发文本:钉钉 errcode 原样透出(310000 关键词/加签不匹配是最常见的坑)', async () => {
const stub = stubFetch({ body: { errcode: 310000, errmsg: 'sign not match' } });
try {
await assert.rejects(
() => sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
secret: 'SECwrong',
content: '【仓储物流转运异常】测试',
}),
(error) => {
assert.equal(error.code, 310000);
assert.match(error.message, /310000/);
assert.match(error.message, /sign not match/);
return true;
}
);
} finally {
stub.restore();
}
});
test('发文本:超长正文自动截断后仍会发出去(不是直接抛错)', async () => {
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
try {
const huge = '异常'.repeat(12000); // 24000 字 ≈ 72000 字节,远超上限
const result = await sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
content: huge,
});
assert.equal(result.truncated, true);
assert.ok(result.bytes <= sender.MAX_CONTENT_BYTES);
const body = JSON.parse(stub.calls[0].options.body);
assert.ok(Buffer.byteLength(body.text.content, 'utf8') <= sender.MAX_CONTENT_BYTES);
} finally {
stub.restore();
}
});

View File

@ -32,3 +32,28 @@ export const getWarehouseLogisticsExceptionExportRows = (params = {}) => request
headers: { 'Cache-Control': 'no-cache' },
}
);
// ── 每天定点推文本到钉钉群 ────────────────────────────────────────────────
// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选)
// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview)
export const getWarehouseLogisticsExceptionNotifySettings = () => request.get(
'/api/warehouse-logistics-exception/notify/settings',
{ timeout: 30000, headers: { 'Cache-Control': 'no-cache' } }
);
// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填
export const saveWarehouseLogisticsExceptionNotifySettings = (payload = {}) => request.put(
'/api/warehouse-logistics-exception/notify/settings',
payload,
{ timeout: 30000 }
);
// 立即推送 / 预览正文 / 测试通道
// dryRun=true 只组装消息不发送(返回正文全文)
// probe=true 只发一句通道测试,不查业务数据
// 冷启动要全量扫一遍物流宽表(生产实测 3~4 分钟),超时给足
export const runWarehouseLogisticsExceptionNotify = (payload = {}) => request.post(
'/api/warehouse-logistics-exception/notify/run',
payload,
{ timeout: 10 * 60 * 1000 }
);

View File

@ -1,13 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, DatePicker, Dropdown, Empty, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
Alert, Button, Card, Collapse, DatePicker, Dropdown, Empty, Input, InputNumber, Modal, Space, Spin, Statistic,
Switch, Table, Tag, TimePicker, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import { BellOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmPage } from '../../components/Common';
import {
getWarehouseLogisticsExceptions,
getWarehouseLogisticsExceptionExportRows,
getWarehouseLogisticsExceptionNotifySettings,
saveWarehouseLogisticsExceptionNotifySettings,
runWarehouseLogisticsExceptionNotify,
} from '../../api/warehouseLogisticsException';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
@ -78,6 +82,70 @@ const EXPORT_MENU_ITEMS = [
{ key: 'csv', label: '导出 CSV(.csv)' },
];
// 「异常通知」表单初始值。加签密钥永远不回显明文:留空 = 沿用服务端已存的那份
const EMPTY_NOTIFY_FORM = {
enabled: false,
sendTime: '09:10',
webhookUrl: '',
webhookSecret: '',
maxDetailRows: 30,
atAll: false,
pageUrl: '',
};
// 后端「没推」的原因 → 人话(后端 reason 是给日志用的英文标识)
const NOTIFY_REASON_TEXT = {
disabled: '推送开关没打开',
not_configured: 'Webhook 地址还没填',
already_sent_today: '今天已经推过了(一天只推一次)',
not_started: '这一批还没到核验时刻,没有结论可推',
dry_run: '仅预览,未发送',
};
// 钉钉群机器人 webhook 怎么拿 —— 业务自己配,写清楚省得来问
const NOTIFY_HELP_ITEMS = [
{
key: 'webhook',
label: '① 拿群机器人 Webhook 地址',
children: (
<div className="wle-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="wle-notify__help">
添加机器人时如果安全设置选了 <b>加签</b>,把以 <code>SEC</code> 开头的密钥填到「加签密钥」里。
<br />
如果选的是 <b>自定义关键词</b>,密钥留空即可 —— 但关键词必须包含 <b>异常</b>
(本模块消息抬头固定是「【仓储物流转运异常】」,天然命中)。
<br />
如果选的是 <b>IP 地址(段)</b>,需要把生产服务器 IP 加进白名单,密钥同样留空。
</div>
),
},
{
key: 'why',
label: '③ 为什么群里只有文字,没有 Excel 附件',
children: (
<div className="wle-notify__help">
钉钉的<b>自定义机器人 webhook</b> 只支持 text / markdown / link / actionCard,
<b>不支持发文件</b>。所以这里推的是<b>文本消息</b>:汇总数字 + 前 N 条明细 + 页面链接。
<br />
要全量明细或 Excel,点消息末尾的链接进页面,用右上角「导出」按钮导出即可
(口径与消息完全一致)。
</div>
),
},
];
const DETAIL_COLUMNS = [
{
title: '发货仓',
@ -188,6 +256,15 @@ function WarehouseLogisticsException() {
const [verifyDate, setVerifyDate] = useState(null);
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
const [exporting, setExporting] = 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);
const load = useCallback(async (force = false) => {
setLoading(true);
@ -282,6 +359,129 @@ function WarehouseLogisticsException() {
}
};
// ── 异常通知:每天定点把异常情况以文本推到钉钉群 ──────────────────────────
// 后端只回加签密钥掩码,所以表单里的 webhookSecret 永远是空的 —— 留空 = 沿用已存的
const applyNotifyConfig = useCallback((config) => {
setNotifyConfig(config || null);
setNotifyForm({
enabled: config?.enabled === true,
sendTime: config?.sendTime || '09:10',
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 getWarehouseLogisticsExceptionNotifySettings();
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,
sendTime: notifyForm.sendTime,
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 saveWarehouseLogisticsExceptionNotifySettings(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 runWarehouseLogisticsExceptionNotify(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,
batchLabel: info.batch?.label || '',
});
message.success('已生成预览(未发送),正文见下方');
} else if (mode === 'probe') {
message.success('通道测试已发出,去钉钉群看有没有收到那条测试消息');
} else if (info.sent) {
setNotifyPreview(null);
message.success(`已推送到钉钉群(${info.rowCount ?? 0} 条异常,列出 ${info.listedCount ?? 0} 条)`);
} else {
message.warning(`未推送:${NOTIFY_REASON_TEXT[info.reason] || info.reason || '未知原因'}`);
}
const latest = await getWarehouseLogisticsExceptionNotifySettings();
if (latest?.data) applyNotifyConfig(latest.data);
} catch (runError) {
message.error(runError?.response?.data?.error || runError?.message || '推送到钉钉群失败');
} finally {
setNotifyRunning(false);
}
};
const notifyTimeValue = useMemo(() => {
const [hours, minutes] = String(notifyForm.sendTime || '09:10')
.split(':')
.map((part) => Number(part) || 0);
return dayjs().hour(hours).minute(minutes).second(0);
}, [notifyForm.sendTime]);
const notifyLastState = notifyConfig?.lastState || {};
return (
<SycmPage className="wle-page">
<header className="wle-header">
@ -349,6 +549,7 @@ function WarehouseLogisticsException() {
>
<Button icon={<DownloadOutlined />} loading={exporting}>导出</Button>
</Dropdown>
<Button icon={<BellOutlined />} onClick={openNotifySettings}>异常通知</Button>
</Space>
</div>
</Card>
@ -603,6 +804,161 @@ function WarehouseLogisticsException() {
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
</Text>
<Modal
title="异常通知 · 每天把异常明细推到钉钉群"
open={notifyOpen}
onCancel={() => setNotifyOpen(false)}
width={720}
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
? `通道已配好,每天 ${notifyForm.sendTime} 自动推送(0 条异常也推,用来确认任务在跑)`
: 'Webhook 地址还没配,填好保存后点「测试通道」验一下'}
description={notifyConfig?.ready ? null : (
<span>
缺:<b>{(notifyConfig?.missingFields || []).join(' / ') || '(尚未读取)'}</b>
</span>
)}
/>
<div className="wle-notify__row">
<span className="wle-notify__label">每天推送</span>
<Switch
checked={notifyForm.enabled}
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
checkedChildren="开"
unCheckedChildren="关"
/>
<TimePicker
format="HH:mm"
value={notifyTimeValue}
allowClear={false}
minuteStep={5}
style={{ width: 96 }}
onChange={(value) => {
if (!value) return;
setNotifyForm((old) => ({ ...old, sendTime: value.format('HH:mm') }));
}}
/>
<Text type="secondary">核验在 09:00,推送默认 09:10(早于核验点会推空,系统自己会跳过)</Text>
</div>
<div className="wle-notify__row">
<span className="wle-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: 360 }}
/>
</div>
<div className="wle-notify__row">
<span className="wle-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: 300 }}
/>
</div>
<div className="wle-notify__row">
<span className="wle-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 = 只发汇总);钉钉文本上限 20000 字节,超了会自动精简
</Text>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">@所有人</span>
<Switch
checked={notifyForm.atAll}
onChange={(checked) => setNotifyForm((old) => ({ ...old, atAll: checked }))}
checkedChildren="开"
unCheckedChildren="关"
/>
<Text type="secondary">打开后每批异常都会 @所有人(有异常才推,0 条也会推一条平安消息)</Text>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">页面链接</span>
<Input
value={notifyForm.pageUrl}
placeholder="消息末尾「查看全部」用的地址(留空用默认生产地址)"
onChange={(e) => setNotifyForm((old) => ({ ...old, pageUrl: e.target.value }))}
style={{ flex: 1, minWidth: 360 }}
/>
</div>
{notifyPreview ? (
<div className="wle-notify__preview">
<div className="wle-notify__preview-head">
<Text type="secondary">
{notifyPreview.batchLabel || '预览'}
{' '}| 异常 <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="wle-notify__last">
<Text type="secondary">
上次推送:
{notifyLastState.lastSentDate
? <> {notifyLastState.lastSentDate} {String(notifyLastState.lastSentAt || '').slice(11, 19)}
{' '}· 异常 {notifyLastState.rowCount ?? 0} 条
{' '}· 列出 {notifyLastState.listedCount ?? '—'} 条
{notifyLastState.truncated ? '(超长已精简)' : ''}</>
: ' 还没有成功推送记录'}
</Text>
{notifyLastState.lastError ? (
<div className="wle-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>
</SycmPage>
);
}

View File

@ -276,3 +276,70 @@
gap: 2px 10px;
}
}
/* ── 异常通知(每天推 Excel 到钉钉群)───────────────────────────── */
.wle-notify__row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wle-notify__label {
flex: 0 0 88px;
font-size: 13px;
color: var(--sycm-text-secondary);
}
.wle-notify__last {
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
}
.wle-notify__error {
margin-top: 4px;
}
.wle-notify__help {
font-size: 12px;
line-height: 1.8;
color: var(--sycm-text-secondary);
}
.wle-notify__help code {
padding: 0 4px;
border-radius: 3px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04));
}
.wle-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;
}
.wle-notify__preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wle-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;
}