feat(wst): 发货时效两轮核验完成后各推一次钉钉群 #86
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
659
backend/src/jobs/warehouseShipTimelinessNotifyJob.js
Normal file
659
backend/src/jobs/warehouseShipTimelinessNotifyJob.js
Normal 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,
|
||||
};
|
||||
373
backend/src/services/warehouseShipTimelinessNotifySettings.js
Normal file
373
backend/src/services/warehouseShipTimelinessNotifySettings.js
Normal 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,
|
||||
};
|
||||
282
backend/test/warehouseShipTimelinessNotify.test.js
Normal file
282
backend/test/warehouseShipTimelinessNotify.test.js
Normal 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);
|
||||
});
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
@ -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 还没发出的,也含今天之前审核至今未发的历史单)。
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user