Compare commits
37 Commits
codex/luna
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9924c8d1c9 | |||
| 936d391142 | |||
| 2f44cdce82 | |||
| 3062182417 | |||
| 9a7215a46c | |||
| 6c4f36f9fd | |||
| f8b99151b5 | |||
| c5a8b592a1 | |||
| 54ad6aad89 | |||
| 555ac6b116 | |||
| b955e78ba2 | |||
| 54518ab4d3 | |||
| 1139e4c364 | |||
| c0e48688c1 | |||
| cb7d06c20c | |||
| 6b6dfdd1da | |||
| 983fee0d8a | |||
| 85c48ebe77 | |||
| e7e0b96b46 | |||
| 6a5f6e8ae0 | |||
| 6c2686784d | |||
| 776a2cbde2 | |||
| 913827000a | |||
| f749bcc03c | |||
| 33bfc7eacc | |||
| f766549b1a | |||
| a839d78d56 | |||
| dd58364593 | |||
| d1bf4047e2 | |||
| e02d1f6d66 | |||
| 38b902e35a | |||
| 538f8edcbf | |||
| c9e84f2151 | |||
| 9d80b4c73e | |||
| db1185c19f | |||
| 4e4f8890c1 | |||
| 8c96877244 |
@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
|
* 仓储中心发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
* GET /api/warehouse-ship-timeliness/list
|
* GET /api/warehouse-ship-timeliness/list
|
||||||
* query:
|
* query:
|
||||||
@ -11,12 +11,33 @@
|
|||||||
* GET /api/warehouse-ship-timeliness/rounds
|
* GET /api/warehouse-ship-timeliness/rounds
|
||||||
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
||||||
*
|
*
|
||||||
|
* GET /api/warehouse-ship-timeliness/export (2026-10-27 新增)
|
||||||
|
* 「超时未发」明细导出:不分页、不截断,返回本轮全部行(与 /list 同一份缓存)
|
||||||
|
* query: round / force
|
||||||
|
*
|
||||||
|
* ── 两轮核验完成后推钉钉群(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
|
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
|
const {
|
||||||
|
getShipTimeliness,
|
||||||
|
listShipTimelinessExportRows,
|
||||||
|
} = require('../services/warehouseShipTimelinessService');
|
||||||
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
||||||
|
const {
|
||||||
|
getShipTimelinessNotifyAdminConfig,
|
||||||
|
getShipTimelinessNotifyRuntimeConfig,
|
||||||
|
loadShipTimelinessNotifyState,
|
||||||
|
missingFields,
|
||||||
|
saveShipTimelinessNotifyConfig,
|
||||||
|
} = require('../services/warehouseShipTimelinessNotifySettings');
|
||||||
|
const { pushShipTimelinessNotify } = require('../jobs/warehouseShipTimelinessNotifyJob');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -59,4 +80,95 @@ router.get('/rounds', async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出「超时未发」明细:**不分页、不截断**,返回本轮考核口径内的全部行。
|
||||||
|
*
|
||||||
|
* query: round(同 /list)、force=1(绕过缓存重查)
|
||||||
|
*
|
||||||
|
* 与 /list 共用 getShipTimeliness(同一份缓存),所以页面刚刷过时这里是秒回。
|
||||||
|
* 前端拿到 rows 后按选中的仓在前端过滤 —— 口径与页面完全一致,不需要第二套 filter。
|
||||||
|
*/
|
||||||
|
router.get('/export', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await listShipTimelinessExportRows(req.query || {});
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '导出超时未发明细失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 两轮核验完成后推钉钉群 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端
|
||||||
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@ -76,6 +76,24 @@ function isTransientError(error) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ `options.timeoutMs` 是个「看着能用、其实没人读」的参数 —— 历史踩坑,留个告警别再犯。
|
||||||
|
*
|
||||||
|
* 根因:mssql 12.x 上请求级 `request.timeout` 不生效,超时只能靠**池配置级** requestTimeout,
|
||||||
|
* 而共享池(query())的池级超时在 pool 创建时就定死了,调用方改不了。想放宽只有两条路:
|
||||||
|
* · querySlow(sql, params, { connectRequestTimeoutMs }) —— 临时独立连接,可任意放宽;
|
||||||
|
* · query(sql, params, { requestTimeout }) —— 只对独立连接路径(queryWithFreshConnection)有意义。
|
||||||
|
* 2026-11-14 实况:WLE 服务层两处 `{ timeoutMs: 240000 }` 想着「给足 4 分钟」,
|
||||||
|
* 实际一路按 60s/300s 跑,11-13 那轮钉钉推送就因此超时没推出去。
|
||||||
|
*/
|
||||||
|
function warnLegacyTimeoutOption(options, caller) {
|
||||||
|
if (!options || options.timeoutMs === undefined) return;
|
||||||
|
console.warn(
|
||||||
|
`[CloudDB] ${caller} 收到无效参数 timeoutMs=${options.timeoutMs}(已被忽略,不会放宽超时);`
|
||||||
|
+ '需要更长超时请改用 querySlow + connectRequestTimeoutMs'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isTruthy(value) {
|
function isTruthy(value) {
|
||||||
return value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1';
|
return value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1';
|
||||||
}
|
}
|
||||||
@ -1036,6 +1054,7 @@ const resetPool = async () => {
|
|||||||
* @param {object} params - 命名参数对象
|
* @param {object} params - 命名参数对象
|
||||||
*/
|
*/
|
||||||
const query = async (sqlText, params = {}, options = {}) => {
|
const query = async (sqlText, params = {}, options = {}) => {
|
||||||
|
warnLegacyTimeoutOption(options, 'query()');
|
||||||
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
|
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
@ -1072,6 +1091,7 @@ const query = async (sqlText, params = {}, options = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
|
const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
|
||||||
|
warnLegacyTimeoutOption(options, 'queryWithFreshConnection()');
|
||||||
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
|
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
|||||||
@ -72,6 +72,9 @@ const {
|
|||||||
const {
|
const {
|
||||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||||
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
|
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
|
||||||
|
const {
|
||||||
|
startShipTimelinessNotifyScheduler,
|
||||||
|
} = require('./jobs/warehouseShipTimelinessNotifyJob');
|
||||||
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||||
const { ensureLocalSchema } = require('./services/localSchema');
|
const { ensureLocalSchema } = require('./services/localSchema');
|
||||||
const {
|
const {
|
||||||
@ -678,7 +681,7 @@ async function startServer(options = {}) {
|
|||||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||||
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
// 仓储中心发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||||
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
|
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
|
||||||
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
|
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
|
||||||
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
|
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
|
||||||
@ -798,6 +801,7 @@ async function startServer(options = {}) {
|
|||||||
startAlibaba1688ImageSearchExpiryScheduler();
|
startAlibaba1688ImageSearchExpiryScheduler();
|
||||||
startMkhReplenishAlertScheduler();
|
startMkhReplenishAlertScheduler();
|
||||||
startWarehouseLogisticsExceptionNotifyScheduler();
|
startWarehouseLogisticsExceptionNotifyScheduler();
|
||||||
|
startShipTimelinessNotifyScheduler();
|
||||||
startDailyInventorySnapshotScheduler();
|
startDailyInventorySnapshotScheduler();
|
||||||
|
|
||||||
// Keep the first selection-pool page request from paying the remote schema
|
// Keep the first selection-pool page request from paying the remote schema
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
* 业务口径(2026-10-22 定稿):
|
* 业务口径(2026-10-22 定稿):
|
||||||
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
|
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
|
||||||
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
|
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
|
||||||
|
* ⚠️ 失败必须重试(2026-11-11 加):09:10 起 30 分钟内每 5 分钟再试一次,成功即停;
|
||||||
|
* 窗口过了不再补推(上午的数据下午才发会误导业务)。见 `resolveScheduledPushDecision`。
|
||||||
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
|
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
|
||||||
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
|
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
|
||||||
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
|
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
|
||||||
@ -20,6 +22,9 @@
|
|||||||
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
|
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
|
||||||
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
|
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
|
||||||
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
|
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
|
||||||
|
* ⑤ 数据新鲜度必须进正文(2026-11-11 加):只要算出异常,就要说明物流快照
|
||||||
|
* 是否覆盖本轮核验时刻、是否过旧 —— 否则「990 单 100% 异常」到底是真异常
|
||||||
|
* 还是镜像表停在昨天,群里看不出来。见 `buildFreshnessWarning`。
|
||||||
*
|
*
|
||||||
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
|
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
|
||||||
*/
|
*/
|
||||||
@ -42,6 +47,32 @@ const DEFAULT_TICK_CRON = '* * * * *';
|
|||||||
// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败
|
// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败
|
||||||
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
||||||
|
|
||||||
|
// 🔥 2026-11-11 加:推送失败要重试,不能「一天只有一次机会」。
|
||||||
|
// 实况:11-11 09:10 那次撞上业务库连不上(状态里 `Failed to connect to 218.93.9.21:1433`),
|
||||||
|
// 老逻辑只在 `HH:mm === sendTime` 这一分钟内跑一次,失败后当天彻底不推 —— 群里一整天没消息。
|
||||||
|
// 现在改成:从推送时刻起 `RETRY_WINDOW_MINUTES` 分钟内,每隔 `RETRY_INTERVAL_MINUTES` 分钟再试,
|
||||||
|
// 成功即停(`lastSentDate` 落库后后续 tick 自动 skip),窗口过了就不再补(避免上午的数据下午才发)。
|
||||||
|
// 两个值都可用环境变量覆盖,现网想调不用改前端配置、也不用发版。
|
||||||
|
const DEFAULT_RETRY_WINDOW_MINUTES = 30;
|
||||||
|
const DEFAULT_RETRY_INTERVAL_MINUTES = 5;
|
||||||
|
|
||||||
|
function readPositiveMinutes(value, fallback, max) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num) || num <= 0) return fallback;
|
||||||
|
return Math.min(Math.round(num), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRY_WINDOW_MINUTES = readPositiveMinutes(
|
||||||
|
process.env.WLE_NOTIFY_RETRY_WINDOW_MINUTES,
|
||||||
|
DEFAULT_RETRY_WINDOW_MINUTES,
|
||||||
|
12 * 60
|
||||||
|
);
|
||||||
|
const RETRY_INTERVAL_MINUTES = readPositiveMinutes(
|
||||||
|
process.env.WLE_NOTIFY_RETRY_INTERVAL_MINUTES,
|
||||||
|
DEFAULT_RETRY_INTERVAL_MINUTES,
|
||||||
|
RETRY_WINDOW_MINUTES
|
||||||
|
);
|
||||||
|
|
||||||
let tickRunning = false;
|
let tickRunning = false;
|
||||||
|
|
||||||
function pad2(value) {
|
function pad2(value) {
|
||||||
@ -72,6 +103,66 @@ function chinaHourMinute(date = new Date()) {
|
|||||||
}).format(base);
|
}).format(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 'HH:mm' → 当天第几分钟(09:10 → 550)。非法输入返回 null,调用方按「不跑」处理 */
|
||||||
|
function minuteOfDay(hhmm) {
|
||||||
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(hhmm || '').trim());
|
||||||
|
if (!match) return null;
|
||||||
|
return Number(match[1]) * 60 + Number(match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时器每一分钟问一次「现在该不该推」。纯函数,便于把时间窗口/节流的边界钉死在单测里。
|
||||||
|
*
|
||||||
|
* 返回 `{ run, reason, pushDate, attempt }`:
|
||||||
|
* · `already_sent_today` 今天已推成功 → 不再动(重试自然也停)
|
||||||
|
* · `before_time` 还没到配置的推送时刻
|
||||||
|
* · `window_closed` 已过「推送时刻 + 重试窗口」,不再补推(避免 10 点失败、下午才发出去)
|
||||||
|
* · `wait_interval` 上一轮失败距现在不到重试间隔,等着
|
||||||
|
* · `first_round` 到点第一次跑
|
||||||
|
* · `retry` 上一轮失败后的第 N 次重试
|
||||||
|
*
|
||||||
|
* ⚠️ 窗口**不跨日**:`sendTime + window` 超过 23:59 就截到当天最后一分钟。
|
||||||
|
* 跨日会让 `pushDate` 换天,去重键(`lastSentDate`)跟着变,容易出现同一天推两遍。
|
||||||
|
*/
|
||||||
|
function resolveScheduledPushDecision(options = {}) {
|
||||||
|
const now = options.now instanceof Date ? options.now : new Date(options.now ?? Date.now());
|
||||||
|
const pushDate = chinaDateKey(now);
|
||||||
|
const state = options.state && typeof options.state === 'object' ? options.state : {};
|
||||||
|
const windowMinutes = readPositiveMinutes(options.retryWindowMinutes, RETRY_WINDOW_MINUTES, 12 * 60);
|
||||||
|
const intervalMinutes = readPositiveMinutes(options.retryIntervalMinutes, RETRY_INTERVAL_MINUTES, windowMinutes);
|
||||||
|
|
||||||
|
if (state.lastSentDate === pushDate) {
|
||||||
|
return { run: false, reason: 'already_sent_today', pushDate, attempt: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMinute = minuteOfDay(chinaHourMinute(now));
|
||||||
|
const startMinute = minuteOfDay(options.sendTime);
|
||||||
|
if (nowMinute === null || startMinute === null) {
|
||||||
|
return { run: false, reason: 'bad_send_time', pushDate, attempt: 0 };
|
||||||
|
}
|
||||||
|
if (nowMinute < startMinute) {
|
||||||
|
return { run: false, reason: 'before_time', pushDate, attempt: 0 };
|
||||||
|
}
|
||||||
|
const endMinute = Math.min(startMinute + windowMinutes, 23 * 60 + 59);
|
||||||
|
if (nowMinute > endMinute) {
|
||||||
|
return { run: false, reason: 'window_closed', pushDate, attempt: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当天的尝试次数:跨天或没有记录都归零,避免沿用昨天的计数把节流算歪
|
||||||
|
const attempt = state.attemptDate === pushDate ? Number(state.attemptCount) || 0 : 0;
|
||||||
|
if (attempt > 0) {
|
||||||
|
const lastAttemptMs = Date.parse(String(state.lastAttemptAt || ''));
|
||||||
|
if (Number.isFinite(lastAttemptMs)) {
|
||||||
|
const gapMinutes = (now.getTime() - lastAttemptMs) / 60000;
|
||||||
|
if (gapMinutes < intervalMinutes) {
|
||||||
|
return { run: false, reason: 'wait_interval', pushDate, attempt };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { run: true, reason: attempt > 0 ? 'retry' : 'first_round', pushDate, attempt: attempt + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 字段清洗:换行/连续空白压成单空格。
|
* 字段清洗:换行/连续空白压成单空格。
|
||||||
* ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。
|
* ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。
|
||||||
@ -208,6 +299,51 @@ function buildTypeLine(data = {}) {
|
|||||||
.join(' · ');
|
.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快照时刻的展示形式:`2026-11-11 09:05:00` → `09:05`;**跨天则带 `MM-DD`**。
|
||||||
|
* ⚠️ 只显示 HH:mm 会把「昨天 11:53」读成「今天 11:53」。11-11 那批的 `snapshotAt`
|
||||||
|
* 其实是前一天的(镜像表停在 11-10 11:53),群里看到「快照最新只到 11:53」会误以为
|
||||||
|
* 当天上午的数据很新鲜 —— 与 WST「历史单审核时间必须带 MM-DD」是同一个坑。
|
||||||
|
*/
|
||||||
|
function formatSnapshotMoment(value, referenceDate = '') {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (text.length < 16) return text;
|
||||||
|
const datePart = text.slice(0, 10);
|
||||||
|
const timePart = text.slice(11, 16);
|
||||||
|
const refDate = String(referenceDate || '').trim().slice(0, 10);
|
||||||
|
if (refDate && datePart === refDate) return timePart;
|
||||||
|
return `${datePart.slice(5)} ${timePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快照新鲜度告警 —— **有异常数据时才用**(0 单的情形由 `buildNotifyText` 里的 emptyWindow 分支处理)。
|
||||||
|
*
|
||||||
|
* 🔥 2026-11-11 实况:物流镜像表停在 11-10 11:53,09:10 那批算出 990 单**全部异常**,
|
||||||
|
* 正文却只有一句「异常 990 个(100%)」。群里没法分辨这是真异常还是数据没更新,
|
||||||
|
* 而这恰恰决定当天要不要去催快递。所以「没覆盖核验时刻」「快照过旧」必须写进正文,
|
||||||
|
* 不能只留在页面 hint 里 —— 群里干活的人看不到页面。
|
||||||
|
*/
|
||||||
|
function buildFreshnessWarning(freshness = {}, batch = {}) {
|
||||||
|
const info = freshness && typeof freshness === 'object' ? freshness : {};
|
||||||
|
const snapshotAt = formatSnapshotMoment(info.snapshotAt, batch.verifyDate);
|
||||||
|
|
||||||
|
if (info.coversDeadline === false) {
|
||||||
|
const deadline = formatSnapshotMoment(batch.deadline, batch.verifyDate);
|
||||||
|
return `⚠️ 数据未覆盖核验时刻:物流快照最新只到 ${snapshotAt || '未知'}`
|
||||||
|
+ `${deadline ? `,本轮核验 ${deadline}` : ''} —— 之后的揽收/中转轨迹没进库,异常量会偏大。`
|
||||||
|
+ '请以聚水潭实时物流为准,别按这个数去催快递。';
|
||||||
|
}
|
||||||
|
if (info.stale === true) {
|
||||||
|
const ageMinutes = Number(info.ageMinutes);
|
||||||
|
const hours = Number.isFinite(ageMinutes) && ageMinutes > 0
|
||||||
|
? `(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新)`
|
||||||
|
: '';
|
||||||
|
return `⚠️ 物流快照偏旧:最新只到 ${snapshotAt || '未知'}${hours} —— 状态可能滞后,`
|
||||||
|
+ '异常量仅供参考,请以聚水潭实时物流为准。';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function buildPageLink(pageUrl, verifyDate) {
|
function buildPageLink(pageUrl, verifyDate) {
|
||||||
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
||||||
if (!verifyDate) return base;
|
if (!verifyDate) return base;
|
||||||
@ -245,15 +381,44 @@ function buildNotifyText(data = {}, options = {}) {
|
|||||||
const typeLine = buildTypeLine(data);
|
const typeLine = buildTypeLine(data);
|
||||||
if (typeLine) head.push(`类型:${typeLine}`);
|
if (typeLine) head.push(`类型:${typeLine}`);
|
||||||
|
|
||||||
|
// 快照新鲜度告警:只对「算出了异常」的批次有意义 —— 11-11 那批 990 单全部异常,
|
||||||
|
// 正文却完全看不出数据停在昨天,业务只能凭印象判断要不要催快递。
|
||||||
|
const freshness = data.freshness || {};
|
||||||
|
const freshnessWarning = totalPackages > 0 ? buildFreshnessWarning(freshness, batch) : '';
|
||||||
|
if (freshnessWarning) head.push(freshnessWarning);
|
||||||
|
|
||||||
|
// 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」——
|
||||||
|
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
|
||||||
|
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
|
||||||
if (totalPackages === 0) {
|
if (totalPackages === 0) {
|
||||||
|
if (freshness.emptyWindow && freshness.sourceStale) {
|
||||||
|
const hours = Number(freshness.sourceAgeMinutes) > 0
|
||||||
|
? `(已 ${Math.round((Number(freshness.sourceAgeMinutes) / 60) * 10) / 10} 小时未同步)`
|
||||||
|
: '';
|
||||||
|
head.push(`⚠️ 物流数据未同步:镜像表最后更新 ${freshness.sourceLastUpdate || '未知'}${hours},`
|
||||||
|
+ `${batch.shipDate || '该日'} 的发货记录一条都没进库 —— 上面 0 单不代表当天没发货,请先到聚水潭核对。`);
|
||||||
|
} else if (freshness.emptyWindow) {
|
||||||
|
head.push(`${batch.shipDate || '该日'} 在物流镜像表里 0 条发货记录`
|
||||||
|
+ `(表最后更新 ${freshness.sourceLastUpdate || '未知'},数据源本身正常)——`
|
||||||
|
+ ' 若当天确有发货,说明上游漏同步了这一天,请到聚水潭核对。');
|
||||||
|
} else if (shipped === 0) {
|
||||||
|
head.push('本批发货 0 单,无异常可核。');
|
||||||
|
} else {
|
||||||
head.push('本批无转运异常,全部正常。');
|
head.push('本批无转运异常,全部正常。');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||||||
+ (Number(exclusions.voidedPackages) || 0)
|
+ (Number(exclusions.voidedPackages) || 0)
|
||||||
+ (Number(exclusions.nonRealPackages) || 0);
|
+ (Number(exclusions.nonRealPackages) || 0)
|
||||||
|
+ (Number(exclusions.transferPackages) || 0);
|
||||||
if (excludedCount > 0) {
|
if (excludedCount > 0) {
|
||||||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
|
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发,不计考核)`);
|
||||||
|
}
|
||||||
|
// 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母
|
||||||
|
const interceptCount = Number(exclusions.interceptPackages) || 0;
|
||||||
|
if (interceptCount > 0) {
|
||||||
|
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||||
@ -455,8 +620,9 @@ async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', opt
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。
|
* 调度:每分钟问一次 `resolveScheduledPushDecision`。
|
||||||
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
|
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
|
||||||
|
* 之所以不是「HH:mm 严格相等」:那样只给一次机会,09:10 撞上业务库连不上就整天不推(11-11 实况)。
|
||||||
*/
|
*/
|
||||||
function startWarehouseLogisticsExceptionNotifyScheduler() {
|
function startWarehouseLogisticsExceptionNotifyScheduler() {
|
||||||
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
|
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
|
||||||
@ -465,6 +631,7 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
||||||
|
let windowClosedLoggedDate = '';
|
||||||
cron.schedule(expression, () => {
|
cron.schedule(expression, () => {
|
||||||
if (tickRunning) return;
|
if (tickRunning) return;
|
||||||
tickRunning = true;
|
tickRunning = true;
|
||||||
@ -473,8 +640,32 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
|
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
|
||||||
if (!config.enabled) return;
|
if (!config.enabled) return;
|
||||||
if (chinaHourMinute(now) !== config.sendTime) return;
|
const state = await loadWarehouseLogisticsExceptionNotifyState();
|
||||||
await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now });
|
const decision = resolveScheduledPushDecision({ now, sendTime: config.sendTime, state });
|
||||||
|
if (!decision.run) {
|
||||||
|
// 窗口刚过、当天又一直没推成功 → 打一条,明确「今天不会自己来了」,别让人一直等
|
||||||
|
if (decision.reason === 'window_closed' && windowClosedLoggedDate !== decision.pushDate) {
|
||||||
|
windowClosedLoggedDate = decision.pushDate;
|
||||||
|
console.error(
|
||||||
|
`[WleNotify] 今日推送窗口已关闭(${config.sendTime} 起 ${RETRY_WINDOW_MINUTES} 分钟内重试,`
|
||||||
|
+ '始终未成功),今天不再自动重试,请人工处理'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先落「本轮尝试」:失败后下一分钟的 tick 才能按间隔节流。
|
||||||
|
// 成功时 push 内部会覆盖 lastSentDate(去重键),此后本函数直接走 already_sent_today。
|
||||||
|
await saveWarehouseLogisticsExceptionNotifyState({
|
||||||
|
...state,
|
||||||
|
attemptDate: decision.pushDate,
|
||||||
|
attemptCount: decision.attempt,
|
||||||
|
lastAttemptAt: now.toISOString(),
|
||||||
|
});
|
||||||
|
await runWarehouseLogisticsExceptionNotifyJob(
|
||||||
|
decision.reason === 'retry' ? 'daily_retry' : 'daily_schedule',
|
||||||
|
{ now }
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WleNotify] 定时检查失败:', error.message || error);
|
console.error('[WleNotify] 定时检查失败:', error.message || error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -483,23 +674,31 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
|
|||||||
})();
|
})();
|
||||||
}, { timezone: TIMEZONE });
|
}, { timezone: TIMEZONE });
|
||||||
|
|
||||||
console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`);
|
console.log(
|
||||||
|
`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送,`
|
||||||
|
+ `失败后每 ${RETRY_INTERVAL_MINUTES} 分钟重试、最多 ${RETRY_WINDOW_MINUTES} 分钟内 (${TIMEZONE})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
DEFAULT_RETRY_INTERVAL_MINUTES,
|
||||||
|
DEFAULT_RETRY_WINDOW_MINUTES,
|
||||||
DEFAULT_TICK_CRON,
|
DEFAULT_TICK_CRON,
|
||||||
SAFE_CONTENT_BYTES,
|
SAFE_CONTENT_BYTES,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
buildCenterLines,
|
buildCenterLines,
|
||||||
buildDetailLine,
|
buildDetailLine,
|
||||||
|
buildFreshnessWarning,
|
||||||
buildNotifyText,
|
buildNotifyText,
|
||||||
buildTypeLine,
|
buildTypeLine,
|
||||||
chinaDateKey,
|
chinaDateKey,
|
||||||
chinaHourMinute,
|
chinaHourMinute,
|
||||||
groupRowsByWarehouse,
|
groupRowsByWarehouse,
|
||||||
|
minuteOfDay,
|
||||||
pickDetailRows,
|
pickDetailRows,
|
||||||
cleanCell,
|
cleanCell,
|
||||||
pushWarehouseLogisticsExceptionNotify,
|
pushWarehouseLogisticsExceptionNotify,
|
||||||
|
resolveScheduledPushDecision,
|
||||||
runWarehouseLogisticsExceptionNotifyJob,
|
runWarehouseLogisticsExceptionNotifyJob,
|
||||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||||
};
|
};
|
||||||
|
|||||||
693
backend/src/jobs/warehouseShipTimelinessNotifyJob.js
Normal file
693
backend/src/jobs/warehouseShipTimelinessNotifyJob.js
Normal file
@ -0,0 +1,693 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 业务口径(2026-10-27 原话):
|
||||||
|
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
|
||||||
|
* 18:00 核验的订单,核验完成后也通知。」
|
||||||
|
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
|
||||||
|
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
|
||||||
|
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
|
||||||
|
* ⚠️ 2026-10-28 起数据源换成订单两表(jst_Orders / jst_qmcust_orderlist),
|
||||||
|
* 起算点是**审核时间** `last_oa_confirm_time`(不是订单创建时间);
|
||||||
|
* 取不到审核时间的单回退创建时间,正文里在该时间后加 `*` 提示。
|
||||||
|
* 推送正文写「审核 HH:mm」(此前一版误写「创建」,已纠正)。
|
||||||
|
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
|
||||||
|
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
|
||||||
|
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
|
||||||
|
*
|
||||||
|
* 消息内容:
|
||||||
|
* 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等)
|
||||||
|
* + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。
|
||||||
|
* 明细一行 = 仓|内部单号|线上单号|快递|创建 HH:mm|已等 X 小时(业务对账用)。
|
||||||
|
* (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明)
|
||||||
|
* 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。
|
||||||
|
*
|
||||||
|
* 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。
|
||||||
|
* 推送成功才写状态;发送失败绝不写,否则业务配好通道后反而不推了。
|
||||||
|
*
|
||||||
|
* 时区:生产容器是 UTC,一切「今天 / 现在几点」都用 Asia/Shanghai 判。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cron = require('node-cron');
|
||||||
|
const {
|
||||||
|
getShipTimeliness,
|
||||||
|
DEFAULT_DETAIL_LIMIT,
|
||||||
|
EXCLUDED_CARRIERS,
|
||||||
|
} = require('../services/warehouseShipTimelinessService');
|
||||||
|
const { ROUNDS, beijingNow, 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} 天`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 起算时间(= 审核时间 `last_oa_confirm_time`)的展示:
|
||||||
|
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
||||||
|
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
|
||||||
|
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
|
||||||
|
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
|
||||||
|
*
|
||||||
|
* ⚠️ 传进来的 `auditTime` 是服务层的 benchmarkAt:审核时间,取不到时回退订单创建时间
|
||||||
|
* (此时 row.benchmarkSource === 'created',调用方会给时间加 `*`)。
|
||||||
|
*/
|
||||||
|
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,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
||||||
|
* 两者业务都要,别只留一个。
|
||||||
|
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
||||||
|
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
||||||
|
* 不用再回页面一单单点开确认。
|
||||||
|
* 🔥 2026-10-28 第五段是「审核」时间(起算点),不是订单创建时间;
|
||||||
|
* 审核时间缺失、回退订单创建时间的单,时间后面带 `*`。
|
||||||
|
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
|
||||||
|
*/
|
||||||
|
function buildDetailLine(row, index, roundDate) {
|
||||||
|
const when = auditWhen(row?.auditTime, roundDate);
|
||||||
|
const auditText = when
|
||||||
|
? `审核 ${when}${row?.benchmarkSource === 'created' ? '*' : ''}`
|
||||||
|
: '审核 —';
|
||||||
|
const parts = [
|
||||||
|
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
||||||
|
cleanCell(row?.oId, '无单号'),
|
||||||
|
cleanCell(row?.soId, '—'),
|
||||||
|
cleanCell(row?.logisticCompany, '—'),
|
||||||
|
auditText,
|
||||||
|
`已等 ${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) {
|
||||||
|
// 快递清单由服务层给(`excludedCarriers`),别在这里写死 ——
|
||||||
|
// 2026-10-27 加「武进安能」时就吃过一次:写死的文案忘了改,群里口径和页面不一致。
|
||||||
|
const carriers = (Array.isArray(data.excludedCarriers) ? data.excludedCarriers : EXCLUDED_CARRIERS)
|
||||||
|
.filter(Boolean);
|
||||||
|
head.push(`已剔除 ${carrierSkipped} 单不考核快递${carriers.length ? `(${carriers.join(' / ')})` : ''}`);
|
||||||
|
}
|
||||||
|
// 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单
|
||||||
|
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 = {}) {
|
||||||
|
// 🔥 两个「现在」不能混用(生产容器 /etc/timezone = Etc/UTC):
|
||||||
|
// · rawNow —— 真实时刻。凡是走 Intl(Asia/Shanghai) 或 toISOString 的地方用它;
|
||||||
|
// · bjNow —— 平移成「本地 getter 读出来正好是北京时间」的 Date。
|
||||||
|
// registry.describeRounds / resolveRoundMeta / getShipTimeliness 内部都用
|
||||||
|
// `getDate()/getHours()` 直接读,必须喂 bjNow —— 否则北京 00:00~08:00 会取到前一天
|
||||||
|
// (2026-10-21 生产踩过:13:00 轮算出 3895 单,实际只有 6 单)。
|
||||||
|
const rawNow = options.now === undefined ? new Date() : new Date(options.now);
|
||||||
|
const bjNow = beijingNow(rawNow);
|
||||||
|
const pushDate = chinaDateKey(rawNow);
|
||||||
|
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(rawNow);
|
||||||
|
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, bjNow);
|
||||||
|
if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey };
|
||||||
|
|
||||||
|
// 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick)
|
||||||
|
const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16));
|
||||||
|
if (deadlineMinutes != null && minutesOfDay(rawNow) < 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: bjNow,
|
||||||
|
});
|
||||||
|
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: rawNow.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: rawNow.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,
|
||||||
|
};
|
||||||
@ -240,24 +240,27 @@
|
|||||||
// ⚠️ 配件库存**不适用本口径**(配件仍只算 15718196)——新仓一个配件都没有,
|
// ⚠️ 配件库存**不适用本口径**(配件仍只算 15718196)——新仓一个配件都没有,
|
||||||
// 横林三仓/沭阳仓虽有 MKH 配件但归属别的项目,并入会让「配件不足」漏报。
|
// 横林三仓/沭阳仓虽有 MKH 配件但归属别的项目,并入会让「配件不足」漏报。
|
||||||
//
|
//
|
||||||
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
|
// ── ⑧ 在途加工单(2026-09-17 业务确认口径;2026-11-11 换数据源)──────────
|
||||||
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
|
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
|
||||||
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
|
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
|
||||||
// · 数据源:业务库同实例的链接服务器
|
// · 数据源(2026-11-11 起):聚水潭原生加工单三表,主键 `po_id` 关联
|
||||||
// LS_JS_DMP → JSDataMiddlePlatform.dbo.bi_processing_work_orders
|
// rpt.jst_manufactureInfo 加工单单头:status / wms_co_id(加工仓) / receipt_wms_co_id(收货仓) / po_date
|
||||||
// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表)
|
// rpt.jst_manufactureItem 加工单**成品**明细:sku_id / qty(计划数) / io_qty(已入库) / delivery_date
|
||||||
// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓'
|
// rpt.jst_manufactureRaw 加工单**原料**明细:qty(暂未参与在途核算,配件占用走 BOM 单台用量)
|
||||||
// AND 状态 <> '作废' ← 作废单据一律不核算
|
// · 过滤:i.wms_co_id = 15718196(简尚-崔桥工厂半成品包装项目仓,产出入品牌仓 15870495)
|
||||||
// AND max(数量 − 已入库数量, 0) > 0 ← 只认真实剩余量
|
// AND i.status <> 'Cancelled' ← 作废单不核算(实测 178 单 7,515 件,不过滤会虚增几十倍)
|
||||||
// · 在途加工量 = Σ max(数量 − 已入库数量, 0),按 商品编码 归集。
|
// AND it.qty > ISNULL(it.io_qty,0) ← 只认真实剩余量
|
||||||
// 不再依赖「收货状态」文本:上游状态字段延迟时,已入库数量已追平的单据必须立即退出在途。
|
// · 在途加工量 = Σ(qty − io_qty),按成品编码 sku_id 归集;ETA 取 delivery_date 最小值。
|
||||||
// ⚠️ 为什么走 getPool 而不是 cloudDatabase.query:
|
// 状态值域实测(2026-11-11):Finished 1,149 单 / Cancelled 178 / **Confirmed 18**;
|
||||||
// query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`,
|
// Finished 的 1,173 行明细**零剩余**(io_qty 已追平),Confirmed 的 18 行 io_qty 全 0。
|
||||||
// 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成
|
// → 无需依赖状态文本判「完成」,剩余量判据自身就够;状态只用来剔作废。
|
||||||
// `LS_JS_DMP.JSDataMiddlePlatform.rpt.t`。getPool() 不经过该重写。
|
// ⚠️ **换源原因**:旧源是链接服务器 `LS_JS_DMP`(192.168.1.209) 的快照表
|
||||||
// 实测(2026-09-16):全表 679 行,其中 完成 498(全部入库)/ 作废 176 /
|
// `JSDataMiddlePlatform.dbo.bi_processing_work_orders`,自 2026-11-03 10:49 起**停更 8 天**
|
||||||
// 已确认 3 / 待审核 2;属半成品包装仓且未完成且非作废的只有 5 单 40 件。
|
// (1,370 行 / MAX(Id)=1370 不再增长,而此前每天都有 5~34 行),且**不会报错** ——
|
||||||
// ⚠️ 作废单在半成品包装仓有 79 单 740 件,若不过滤会虚增约 18 倍在途量。
|
// `workOrderDegraded` 只反映「查询失败」,数据陈旧永远是 false,
|
||||||
|
// 于是页面长期显示 9 个 11-03 的僵尸单(业务在聚水潭看到的是 18 个新单)。
|
||||||
|
// 换成本实例内的原生三表后随主库小时级刷新,不再依赖那台内网机器的同步任务。
|
||||||
|
// 实测(2026-11-11):18 单 / 18 个成品 / 在途 175 件,ETA 全可解析(11-24 ~ 11-25)。
|
||||||
//
|
//
|
||||||
// ── ⑨ 导出(2026-09-17)──────────────────────────────────────────
|
// ── ⑨ 导出(2026-09-17)──────────────────────────────────────────
|
||||||
// 导出与列表**共用同一套筛选/排序口径**(filterRows + sortRows),区别只有两点:
|
// 导出与列表**共用同一套筛选/排序口径**(filterRows + sortRows),区别只有两点:
|
||||||
@ -465,15 +468,25 @@ const WARNING_LEVEL = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── 在途加工单参数(见文件头 ⑧)──────────────────────────────────
|
// ── 在途加工单参数(见文件头 ⑧)──────────────────────────────────
|
||||||
// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓)
|
// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓)。仅用于文案展示。
|
||||||
const WORK_ORDER_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓';
|
const WORK_ORDER_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓';
|
||||||
// 作废状态:作废单据不参与在途量核算
|
// 加工仓 wms_co_id(半成品包装项目仓):SQL 侧按它过滤。
|
||||||
const WORK_ORDER_VOID_STATUS = '作废';
|
// ⚠️ 用 ID 而不是中文名 —— 名称可能被上游改写,ID 稳定;实测该仓加工单收货仓恒为品牌仓 15870495。
|
||||||
// 收货状态:该值表示已全部入库,等价于「已完成」
|
const WORK_ORDER_WMS_CO_ID = 15718196;
|
||||||
const WORK_ORDER_RECEIVED_STATUS = '全部入库';
|
// 作废状态:作废单据不参与在途量核算(聚水潭加工单 status 值域 Confirmed / Finished / Cancelled)
|
||||||
// 加工单表位于同一实例的链接服务器上,四段名必须走 getPool 查询(原因见文件头 ⑧)
|
const WORK_ORDER_VOID_STATUS = 'Cancelled';
|
||||||
const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_orders';
|
// 完成状态:已全部入库
|
||||||
// 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量
|
const WORK_ORDER_RECEIVED_STATUS = 'Finished';
|
||||||
|
// 聚水潭原生加工单三表(rpt schema 视图,主键 po_id):
|
||||||
|
// Info = 单头(status / wms_co_id / po_date)
|
||||||
|
// Item = 成品明细(sku_id / qty / io_qty 已入库 / delivery_date 预计到货)
|
||||||
|
// Raw = 原料明细(暂未参与在途核算,配件占用走 BOM 单台用量)
|
||||||
|
// ⚠️ 2026-11-11 换源:旧的链接服务器 LS_JS_DMP 快照表自 11-03 起停更 8 天且**不会报错**
|
||||||
|
// (`workOrderDegraded` 只反映查询失败),导致页面 9 个僵尸单 + 新单全丢。
|
||||||
|
// 这三张表在业务库本实例内,可走 cloudDatabase.query()(表名已带 rpt.,不会被重写坏)。
|
||||||
|
const WORK_ORDER_INFO_TABLE = 'rpt.jst_manufactureInfo';
|
||||||
|
const WORK_ORDER_ITEM_TABLE = 'rpt.jst_manufactureItem';
|
||||||
|
// 加工单不可用时的降级缓存时长:短一点,避免长时间带着错误在途量
|
||||||
const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
// ── 订单参数(见文件头 ⑩ 待发货 / ⑦-a 近15天已发货)──────────────
|
// ── 订单参数(见文件头 ⑩ 待发货 / ⑦-a 近15天已发货)──────────────
|
||||||
@ -1392,8 +1405,10 @@ async function loadMkhProductIndex() {
|
|||||||
/**
|
/**
|
||||||
* 未完成加工单(在途量)索引 —— 进程内缓存 { bySku: Map<归一化编码, 在途指标>, degraded }。
|
* 未完成加工单(在途量)索引 —— 进程内缓存 { bySku: Map<归一化编码, 在途指标>, degraded }。
|
||||||
* 只保留「半成品包装项目仓加工、未完成、非作废」的加工单,口径见文件头 ⑧。
|
* 只保留「半成品包装项目仓加工、未完成、非作废」的加工单,口径见文件头 ⑧。
|
||||||
* ⚠️ 该数据源是链接服务器,取不到时**降级为空索引**(补货预警照常跑),
|
* 数据源:聚水潭原生加工单三表 —— jst_manufactureInfo(单头)+ jst_manufactureItem(成品明细),
|
||||||
* 并缩短缓存避免长时间带着错误在途量。degraded=true 会被透出到接口。
|
* PO 关联键 po_id;剩余量 = qty − io_qty(已入库数量)。
|
||||||
|
* ⚠️ 取不到时**降级为空索引**(补货预警照常跑),并缩短缓存避免长时间带着错误在途量。
|
||||||
|
* degraded=true 会被透出到接口。
|
||||||
*/
|
*/
|
||||||
async function loadPendingWorkOrderIndex() {
|
async function loadPendingWorkOrderIndex() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -1403,43 +1418,26 @@ async function loadPendingWorkOrderIndex() {
|
|||||||
if (workOrderIndexCache?.pending) return workOrderIndexCache.pending;
|
if (workOrderIndexCache?.pending) return workOrderIndexCache.pending;
|
||||||
|
|
||||||
const pending = (async () => {
|
const pending = (async () => {
|
||||||
// ⚠️ 必须走 getPool:cloudDatabase.query() 会把 `dbo.xxx` 重写成 `rpt.xxx`,
|
// 表名已带 rpt. 前缀,`query()` 的 dbo→rpt 重写不会碰它,无需 getPool。
|
||||||
// 跨库四段名会被改坏(见文件头 ⑧)。
|
// Finished 单的 io_qty 已追平(实测 1,173 行零剩余),靠 `qty > io_qty` 自然退出,不依赖状态文本。
|
||||||
const pool = await cloudDatabase.getPool();
|
const rows = await cloudDatabase.query(
|
||||||
const request = pool.request();
|
`SELECT it.sku_id AS skuId,
|
||||||
request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE);
|
SUM(it.qty - ISNULL(it.io_qty, 0)) AS pendingQty,
|
||||||
request.input('voidStatus', WORK_ORDER_VOID_STATUS);
|
COUNT(DISTINCT i.po_id) AS orderCount,
|
||||||
const result = await request.query(`
|
MIN(TRY_CONVERT(DATETIME, it.delivery_date, 111)) AS eta,
|
||||||
SELECT [商品编码] AS skuId,
|
MIN(CAST(i.po_id AS NVARCHAR(40))) AS firstOrderNo
|
||||||
SUM(CASE
|
FROM ${WORK_ORDER_ITEM_TABLE} it WITH (NOLOCK)
|
||||||
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
|
JOIN ${WORK_ORDER_INFO_TABLE} i WITH (NOLOCK) ON i.po_id = it.po_id
|
||||||
THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))
|
WHERE i.wms_co_id = @workOrderWmsCoId
|
||||||
ELSE 0
|
AND i.status <> @voidStatus
|
||||||
END) AS pendingQty,
|
AND it.qty > ISNULL(it.io_qty, 0)
|
||||||
COUNT(DISTINCT CASE
|
GROUP BY it.sku_id`,
|
||||||
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
|
{ workOrderWmsCoId: WORK_ORDER_WMS_CO_ID, voidStatus: WORK_ORDER_VOID_STATUS },
|
||||||
THEN [加工单号]
|
{ requestTimeout: 180000 }
|
||||||
END) AS orderCount,
|
);
|
||||||
MIN(CASE
|
|
||||||
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
|
|
||||||
THEN TRY_CONVERT(DATETIME, [预计加工完成时间], 111)
|
|
||||||
END) AS eta,
|
|
||||||
MIN(CASE
|
|
||||||
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
|
|
||||||
THEN CAST([加工单号] AS NVARCHAR(40))
|
|
||||||
END) AS firstOrderNo
|
|
||||||
FROM ${WORK_ORDER_TABLE} WITH (NOLOCK)
|
|
||||||
WHERE [加工仓储方] = @workOrderWarehouse
|
|
||||||
AND [状态] <> @voidStatus
|
|
||||||
GROUP BY [商品编码]
|
|
||||||
HAVING SUM(CASE
|
|
||||||
WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4))
|
|
||||||
THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))
|
|
||||||
ELSE 0
|
|
||||||
END) > 0`);
|
|
||||||
|
|
||||||
const bySku = new Map();
|
const bySku = new Map();
|
||||||
(result.recordset || []).forEach((row) => {
|
(rows || []).forEach((row) => {
|
||||||
const key = normalizeCode(row.skuId);
|
const key = normalizeCode(row.skuId);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0);
|
const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0);
|
||||||
@ -2349,7 +2347,7 @@ async function getMkhBomList(query = {}) {
|
|||||||
operationalRefreshedAt: operationalRefreshedAt
|
operationalRefreshedAt: operationalRefreshedAt
|
||||||
? new Date(operationalRefreshedAt).toISOString()
|
? new Date(operationalRefreshedAt).toISOString()
|
||||||
: null,
|
: null,
|
||||||
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
|
// 加工单数据源不可用(查询失败)时为 true,此时在途量按 0 处理
|
||||||
workOrderDegraded: replenishIndex.workOrderDegraded === true,
|
workOrderDegraded: replenishIndex.workOrderDegraded === true,
|
||||||
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
|
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
|
||||||
// 待发货订单数据源不可用时为 true,此时待发货量按 0 处理(退化为旧口径)
|
// 待发货订单数据源不可用时为 true,此时待发货量按 0 处理(退化为旧口径)
|
||||||
@ -2733,8 +2731,11 @@ module.exports = {
|
|||||||
SCOPES,
|
SCOPES,
|
||||||
WARNING_LEVEL,
|
WARNING_LEVEL,
|
||||||
WORK_ORDER_WAREHOUSE,
|
WORK_ORDER_WAREHOUSE,
|
||||||
|
WORK_ORDER_WMS_CO_ID,
|
||||||
WORK_ORDER_VOID_STATUS,
|
WORK_ORDER_VOID_STATUS,
|
||||||
WORK_ORDER_RECEIVED_STATUS,
|
WORK_ORDER_RECEIVED_STATUS,
|
||||||
|
WORK_ORDER_INFO_TABLE,
|
||||||
|
WORK_ORDER_ITEM_TABLE,
|
||||||
EXPORT_MODES,
|
EXPORT_MODES,
|
||||||
buildProductExportRows,
|
buildProductExportRows,
|
||||||
buildComponentExportRows,
|
buildComponentExportRows,
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* 仓储中心物流转运异常(2026-10-21 新增)
|
* 仓储中心物流转运异常(2026-10-21 新增)
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
* 需求(yanhui 2026-10-21):
|
* 需求(yanhui 2026-10-21):
|
||||||
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
|
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心发货时效」下面),
|
||||||
* 下属 12 个发货仓:
|
* 下属 12 个发货仓:
|
||||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
|
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
|
||||||
@ -158,6 +158,59 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
|
|||||||
{ keyword: '转空包', label: '信誉单' },
|
{ keyword: '转空包', label: '信誉单' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ── 转仓代发(业务 2026-10-23 指定不核对异常;2026-10-24 补充不计原仓发货量)──
|
||||||
|
// 聚水潭「转仓」操作会在 `order_labels` 留一条 `转x00005` 形式的标签
|
||||||
|
// (x + 目标仓编码)。这类单**记账仓是原仓**(如崔桥工厂仓),货却由目标仓
|
||||||
|
// (x00005 = 曹县)实际发出 —— 承运账号也跟着变成曹县韵达小件。
|
||||||
|
// 结果:原仓的物流状态永远停在「未揽收 / 无物流单」,报出来全是假异常。
|
||||||
|
//
|
||||||
|
// 实测近 30 天(12 仓):`转x00005` 438 单,报未揽收类 46 单(10.5%);
|
||||||
|
// 已签收 371 / 无物流单 31 / 运输中 20 / 未揽收 15。
|
||||||
|
// 快递分布:曹县韵达小件 133、邮政快递包裹POSTB 124、睢宁中通 41 ……
|
||||||
|
// 目前只出现过 `转x00005` 一种编码,但标签是机器生成的(后续可能出现 x00006),
|
||||||
|
// 所以用正则匹配「转x<3位以上数字>」,不写死单个编码。
|
||||||
|
// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**。
|
||||||
|
// 🔥 口径:**整单不计** —— 货不是从原仓走的,既不算原仓异常,也不进原仓发货分母
|
||||||
|
// (记进分母会把分母做大、异常率被稀释)。这与「现场拦截」相反,后者货是本仓发的、留分母。
|
||||||
|
const TRANSFER_WAREHOUSE_LABEL_PATTERN = /转\s*[xX]\s*\d{3,}/;
|
||||||
|
|
||||||
|
// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)────────
|
||||||
|
// 业务原话:「把现场拦截的给去掉,也就是发的货被售后签收了」,判据是
|
||||||
|
// **售后单有「签收单号」且「实退数量」不为 0**。
|
||||||
|
//
|
||||||
|
// 这是什么形态:客户不要了 / 派送时被拦下,快递当场把货拉回(轨迹常写
|
||||||
|
// 「异常签收(拦截退回)」),货退回后由售后仓签收入库。物流环节其实已经结束,
|
||||||
|
// 不属于「卡在网点发不出去」,报成转运异常是假警报。
|
||||||
|
//
|
||||||
|
// 落到库里的字段(聚水潭售后单两张表,店铺互斥,必须都读):
|
||||||
|
// · jst_returnAndRefundInfo + jst_returnAndRefundItem (绝大多数店铺)
|
||||||
|
// · jst_qmcust_refundlist + jst_qmcust_refundlist_item (企业微信/千牛客服单)
|
||||||
|
// 签收单号 = 主表 `l_id`(退回那一票的运单号,实测常与原发货单号相同)
|
||||||
|
// 实退数量 = 明细表 `r_qty`(申请数量是 `qty`,**只能看 r_qty**:
|
||||||
|
// 拒收未签收的 r_qty=0、仅退款/补发的 r_qty 恒为 0)
|
||||||
|
//
|
||||||
|
// 实测(2026-10-23 批次,36 个异常包裹)命中 3 单,全是「拒收退货 + r_qty=1 +
|
||||||
|
// l_id 非空」,和业务描述完全一致;同期 9859198/9871523 虽然 l_id 非空但
|
||||||
|
// r_qty=0(货还在路上),正确地没被剔。
|
||||||
|
const INTERCEPT_AFTERSALE_SOURCES = Object.freeze([
|
||||||
|
{
|
||||||
|
key: 'returnAndRefund',
|
||||||
|
header: 'dbo.jst_returnAndRefundInfo',
|
||||||
|
item: 'dbo.jst_returnAndRefundItem',
|
||||||
|
// 主表 as_id=bigint、明细 as_id=int → 显式收窄,避免隐式转换报错
|
||||||
|
headerAsId: 'TRY_CONVERT(int, h.as_id)',
|
||||||
|
label: '售后单',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qmcustRefund',
|
||||||
|
header: 'dbo.jst_qmcust_refundlist',
|
||||||
|
item: 'dbo.jst_qmcust_refundlist_item',
|
||||||
|
// 两张表 as_id 都是 nvarchar,直接等值(别传数字 IN,会把列转 int 撞脏数据)
|
||||||
|
headerAsId: 'h.as_id',
|
||||||
|
label: '客服售后单',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
|
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
|
||||||
const SORTED_KEYWORDS = CENTERS
|
const SORTED_KEYWORDS = CENTERS
|
||||||
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
|
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
|
||||||
@ -316,6 +369,19 @@ function matchNonRealOrder(orderLabels) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转仓代发 → 本仓不用核对物流异常。
|
||||||
|
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
|
||||||
|
* @returns {string|null} 命中的标签原文(如 `转x00005`),便于页脚直接展示;不命中返回 null
|
||||||
|
*/
|
||||||
|
function matchTransferWarehouse(orderLabels) {
|
||||||
|
const labels = String(orderLabels || '');
|
||||||
|
if (!labels) return null;
|
||||||
|
const hit = labels.match(TRANSFER_WAREHOUSE_LABEL_PATTERN);
|
||||||
|
if (!hit) return null;
|
||||||
|
return hit[0].replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析「核验批次」。
|
* 解析「核验批次」。
|
||||||
*
|
*
|
||||||
@ -370,6 +436,7 @@ module.exports = {
|
|||||||
EXCLUDED_ORDER_STATUS_KEYWORDS,
|
EXCLUDED_ORDER_STATUS_KEYWORDS,
|
||||||
EXCLUDED_ORDER_LABEL_KEYWORDS,
|
EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||||||
EXCLUDED_ORDER_TYPE_LABELS,
|
EXCLUDED_ORDER_TYPE_LABELS,
|
||||||
|
INTERCEPT_AFTERSALE_SOURCES,
|
||||||
EXCEPTION_TYPES,
|
EXCEPTION_TYPES,
|
||||||
TYPE_MAP,
|
TYPE_MAP,
|
||||||
TRANSIT_INFO_KEYWORDS,
|
TRANSIT_INFO_KEYWORDS,
|
||||||
@ -379,6 +446,8 @@ module.exports = {
|
|||||||
matchExcludedCarrier,
|
matchExcludedCarrier,
|
||||||
matchVoidedOrder,
|
matchVoidedOrder,
|
||||||
matchNonRealOrder,
|
matchNonRealOrder,
|
||||||
|
matchTransferWarehouse,
|
||||||
|
TRANSFER_WAREHOUSE_LABEL_PATTERN,
|
||||||
resolveBatch,
|
resolveBatch,
|
||||||
beijingNow,
|
beijingNow,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
|||||||
@ -38,6 +38,10 @@ const EXPORT_DETAIL_LIMIT = 5000;
|
|||||||
|
|
||||||
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
||||||
const SNAPSHOT_STALE_MINUTES = 120;
|
const SNAPSHOT_STALE_MINUTES = 120;
|
||||||
|
// 数据源(整张镜像表)多久没刷新算停更。与上面同值,但语义不同:
|
||||||
|
// SNAPSHOT_STALE_MINUTES 判的是「本批窗口内行的 query_time」,
|
||||||
|
// SOURCE_STALE_MINUTES 判的是「整张表最后被灌库的时刻」—— 窗口内 0 行时只能靠后者。
|
||||||
|
const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WLE_SOURCE_STALE_MINUTES) || 120;
|
||||||
|
|
||||||
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||||
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
|
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
|
||||||
@ -148,6 +152,120 @@ async function loadShippedPackages(batch) {
|
|||||||
return rows || [];
|
return rows || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 2026-11-14 修:原来调这里写的是 `{ timeoutMs: 240000 }` —— **这个参数没人读**。
|
||||||
|
// mssql 12.x 上请求级 request.timeout 不生效,超时只能靠池配置级 requestTimeout,
|
||||||
|
// 而共享池(query())的池级超时在 pool 创建时就定死了,改不了。结果是「想给 4 分钟」
|
||||||
|
// 实际一路按默认 60s 掐断(11-14 实测全表 MAX 已经跑不进 60s → 探测永远返回空)。
|
||||||
|
// 真正生效的写法只有 querySlow(临时独立连接)+ connectRequestTimeoutMs。
|
||||||
|
const SOURCE_LAST_UPDATE_TIMEOUT_MS = Number(process.env.CANGKU_WLE_SOURCE_LAST_UPDATE_TIMEOUT_MS)
|
||||||
|
|| 180000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整张镜像表最后被灌库的时刻(表级新鲜度)。
|
||||||
|
*
|
||||||
|
* 🔥 2026-11-02 新增,起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
|
||||||
|
* 业务问「怎么可能 0 单」。查出来镜像表 **11-01 07:55 之后就没再灌过**,
|
||||||
|
* 而 v1 的 freshness 只用「本批窗口内行的 query_time」算 —— 窗口一行都没有时
|
||||||
|
* `snapshotAt` 是空串 → `stale=false`、`hint=''`,页面和钉钉于是把它当成
|
||||||
|
* 「本批确实没有发货 / 全部正常」。这是最危险的一种假绿。
|
||||||
|
*
|
||||||
|
* 成本:全表 `MAX(query_time)` 在一个 402 万行的视图上实测 **13.5s**(谓词下推差),
|
||||||
|
* 所以**只在窗口 0 行时才调**(正常批次一行都不用付这笔钱),结果随主结果进 5 分钟缓存。
|
||||||
|
* ⚠️ 11-14 实测已退化到 60s 以上,所以超时上限 180s + maxAttempts=1
|
||||||
|
* (超时就退回「不知道」,重试只会把等待翻倍 —— 原来 query() 默认 2 次 × 60s 白等)。
|
||||||
|
*
|
||||||
|
* @returns {Promise<string>} 'YYYY-MM-DD HH:mm:ss',取不到返回空串
|
||||||
|
*/
|
||||||
|
async function loadSourceLastUpdate() {
|
||||||
|
try {
|
||||||
|
const rows = await cloudDatabase.querySlow(
|
||||||
|
`SELECT MAX(e.query_time) AS max_q
|
||||||
|
FROM dbo.jst_order_express_details e WITH (NOLOCK)`,
|
||||||
|
{},
|
||||||
|
{ connectRequestTimeoutMs: SOURCE_LAST_UPDATE_TIMEOUT_MS, maxAttempts: 1 }
|
||||||
|
);
|
||||||
|
return formatDateTime(rows?.[0]?.max_q);
|
||||||
|
} catch (err) {
|
||||||
|
// 探测失败不该让整页挂掉:退回「不知道」,hint 里会说明取不到表级时间
|
||||||
|
console.warn(`[WLE] 数据源新鲜度探测失败:${err.message}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 售后拦截判定:一次 IN 多少单 + 查询超时。
|
||||||
|
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
|
||||||
|
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
|
||||||
|
// · 只查「本来就会报异常」的 oId(正常单不查,量级从 9800 降到几十);
|
||||||
|
// · 两套店铺表**并发**查,总耗时取较慢的那条(~8.5s),而不是相加的 13s;
|
||||||
|
// · 结果进 5 分钟结果缓存,页面自动刷新时基本命中缓存。
|
||||||
|
//
|
||||||
|
// 🔥 2026-11-14 修:超时从「名义 240s」改成「真 20s,且不重试」。
|
||||||
|
// 原来写 `{ timeoutMs: 240000 }`(无效参数),实际按共享池 60s 掐;
|
||||||
|
// 超时后 query() 还把它当可重试错误 —— resetPool + 再查一次,**再多等 60s**。
|
||||||
|
// 11-13 09:10 那轮推送就是这么被拖掉 2 分钟的(日志:售后拦截 60s 超时 → 重连重试)。
|
||||||
|
// 售后表跟不上时业务影响只是「少剔几单」,不值得为它把整批核验拖进超时窗口:
|
||||||
|
// 2 倍实测余量(8.5s → 20s)够用,超了就降级不剔(catch 里已有兜底)。
|
||||||
|
const INTERCEPT_QUERY_BATCH = 500;
|
||||||
|
const INTERCEPT_QUERY_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 找出「现场拦截、货已被售后仓签收」的订单(业务 2026-10-23 口径)。
|
||||||
|
*
|
||||||
|
* 判据 = 售后单**签收单号 `l_id` 非空** 且 明细**实退数量 `r_qty` 合计 > 0**。
|
||||||
|
* 两张店铺售后表互斥,必须都查(见 registry.INTERCEPT_AFTERSALE_SOURCES)。
|
||||||
|
*
|
||||||
|
* @param {Array} rows 状态表原始行
|
||||||
|
* @returns {Promise<Set<string>>} 内部单号字符串集合
|
||||||
|
*/
|
||||||
|
async function loadInterceptedOrders(rows) {
|
||||||
|
const ids = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
// 只关心本来会被判成异常的单:正常单(已签收/运输中)剔不剔都一样
|
||||||
|
if (!classify(row)) continue;
|
||||||
|
const id = Number(row.inner_order_id);
|
||||||
|
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
const intercepted = new Set();
|
||||||
|
if (!ids.length) return intercepted;
|
||||||
|
|
||||||
|
const tasks = [];
|
||||||
|
for (let i = 0; i < ids.length; i += INTERCEPT_QUERY_BATCH) {
|
||||||
|
const oIds = ids.slice(i, i + INTERCEPT_QUERY_BATCH).join(',');
|
||||||
|
for (const source of registry.INTERCEPT_AFTERSALE_SOURCES) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const hits = await cloudDatabase.querySlow(
|
||||||
|
`SELECT h.o_id
|
||||||
|
FROM ${source.header} h WITH (NOLOCK)
|
||||||
|
JOIN ${source.item} it WITH (NOLOCK) ON it.as_id = ${source.headerAsId}
|
||||||
|
WHERE h.o_id IN (${oIds})
|
||||||
|
AND ISNULL(h.l_id, '') <> ''
|
||||||
|
GROUP BY h.o_id
|
||||||
|
HAVING SUM(ISNULL(it.r_qty, 0)) > 0`,
|
||||||
|
{},
|
||||||
|
// maxAttempts: 1 —— 超时就在 catch 里降级「不剔除」,别让重连重试再白等一轮
|
||||||
|
{ connectRequestTimeoutMs: INTERCEPT_QUERY_TIMEOUT_MS, maxAttempts: 1 }
|
||||||
|
);
|
||||||
|
return hits || [];
|
||||||
|
})().catch((err) => {
|
||||||
|
// 售后表跟不上不该让整页挂掉:退化成「不剔除」,页脚会显示 0 单,口径说明仍在
|
||||||
|
console.warn(`[WLE] 售后拦截判定失败(${source.key}):${err.message}`);
|
||||||
|
return [];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(tasks);
|
||||||
|
for (const list of results) {
|
||||||
|
for (const r of list) {
|
||||||
|
if (r && r.o_id !== null && r.o_id !== undefined) intercepted.add(String(r.o_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intercepted;
|
||||||
|
}
|
||||||
|
|
||||||
function emptySummary() {
|
function emptySummary() {
|
||||||
return {
|
return {
|
||||||
shippedOrders: 0,
|
shippedOrders: 0,
|
||||||
@ -183,6 +301,25 @@ function exclusionMeta() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存里存的是**全量明细**(不截断),limit 只在出口生效。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-23 修:缓存键以前是 `${核验日}|${limit}`,页面传 1000、导出传 5000
|
||||||
|
* → 两份缓存互不命中,点一次导出就要把整轮重算一遍(生产实测 317s,而实际
|
||||||
|
* 异常只有 33 行,limit 根本不影响扫库成本)。现在键只按核验日,出口再 slice。
|
||||||
|
*/
|
||||||
|
function finalizeDetail(data, limit, cached, cacheAgeSec) {
|
||||||
|
const all = Array.isArray(data.orders) ? data.orders : [];
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
orders: all.slice(0, limit),
|
||||||
|
detailLimit: limit,
|
||||||
|
truncated: all.length > limit,
|
||||||
|
cached,
|
||||||
|
cacheAgeSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getLogisticsExceptions(options = {}) {
|
async function getLogisticsExceptions(options = {}) {
|
||||||
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
|
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
|
||||||
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
|
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
|
||||||
@ -220,29 +357,51 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
nonRealPackages: 0,
|
nonRealPackages: 0,
|
||||||
nonRealOrders: 0,
|
nonRealOrders: 0,
|
||||||
nonRealReasons: {},
|
nonRealReasons: {},
|
||||||
|
interceptPackages: 0,
|
||||||
|
interceptOrders: 0,
|
||||||
|
interceptReasons: {},
|
||||||
|
transferPackages: 0,
|
||||||
|
transferOrders: 0,
|
||||||
|
transferReasons: {},
|
||||||
},
|
},
|
||||||
orders: [],
|
orders: [],
|
||||||
detailLimit: limit,
|
detailLimit: limit,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
|
freshness: {
|
||||||
|
snapshotAt: '',
|
||||||
|
ageMinutes: null,
|
||||||
|
stale: false,
|
||||||
|
coversDeadline: null,
|
||||||
|
emptyWindow: false,
|
||||||
|
sourceLastUpdate: '',
|
||||||
|
sourceAgeMinutes: null,
|
||||||
|
sourceStale: false,
|
||||||
|
hint: '',
|
||||||
|
},
|
||||||
generatedAt,
|
generatedAt,
|
||||||
cached: false,
|
cached: false,
|
||||||
cacheAgeSec: 0,
|
cacheAgeSec: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = `${batch.verifyDate}|${limit}`;
|
// 键只到核验日 —— 页面(limit=1000)与导出(limit=5000)共用同一份全量结果
|
||||||
|
const cacheKey = batch.verifyDate;
|
||||||
const cachedEntry = resultCache.get(cacheKey);
|
const cachedEntry = resultCache.get(cacheKey);
|
||||||
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
|
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
|
||||||
return {
|
return finalizeDetail(
|
||||||
...cachedEntry.data,
|
cachedEntry.data,
|
||||||
cached: true,
|
limit,
|
||||||
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
|
true,
|
||||||
};
|
Math.round((Date.now() - cachedEntry.at) / 1000)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await loadShippedPackages(batch);
|
const rows = await loadShippedPackages(batch);
|
||||||
|
|
||||||
|
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
|
||||||
|
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
|
||||||
|
const interceptedOrders = await loadInterceptedOrders(rows);
|
||||||
|
|
||||||
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||||||
const centerStat = new Map();
|
const centerStat = new Map();
|
||||||
for (const center of registry.CENTERS) {
|
for (const center of registry.CENTERS) {
|
||||||
@ -266,6 +425,8 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
|
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
|
||||||
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
|
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
|
||||||
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
|
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
|
||||||
|
const skippedByIntercept = { packages: 0, orders: new Set(), reasons: {} };
|
||||||
|
const skippedByTransfer = { packages: 0, orders: new Set(), reasons: {} };
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const queryTime = formatDateTime(row.query_time);
|
const queryTime = formatDateTime(row.query_time);
|
||||||
@ -309,6 +470,18 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓实际发出。
|
||||||
|
// 业务 2026-10-24 定版:**原仓的发货量也不该算** —— 货不是从这个仓走的,
|
||||||
|
// 记在原仓只会把发货分母做大、异常率稀释。所以按「整单不计」处理
|
||||||
|
// (既不算异常、也不进发货分母),与承运商/刷单那几类同级,不是「只摘异常」。
|
||||||
|
const transferReason = registry.matchTransferWarehouse(row.order_labels);
|
||||||
|
if (transferReason) {
|
||||||
|
skippedByTransfer.packages += 1;
|
||||||
|
skippedByTransfer.orders.add(oId);
|
||||||
|
skippedByTransfer.reasons[transferReason] = (skippedByTransfer.reasons[transferReason] || 0) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const stat = centerStat.get(center.key);
|
const stat = centerStat.get(center.key);
|
||||||
stat.shippedOrders.add(oId);
|
stat.shippedOrders.add(oId);
|
||||||
stat.packages += 1;
|
stat.packages += 1;
|
||||||
@ -318,11 +491,23 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
const type = classify(row);
|
const type = classify(row);
|
||||||
if (!type) continue;
|
if (!type) continue;
|
||||||
|
|
||||||
|
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||||||
|
|
||||||
|
// ⑤ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
|
||||||
|
// 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。
|
||||||
|
// ⚠️ 与上面几类不同:这里**保留发货分母**(货确实从这个仓发出去了),
|
||||||
|
// 只从异常清单里摘掉,异常率才不会被错误地放大。
|
||||||
|
if (interceptedOrders.has(oId)) {
|
||||||
|
skippedByIntercept.packages += 1;
|
||||||
|
skippedByIntercept.orders.add(oId);
|
||||||
|
skippedByIntercept.reasons[typeMeta.label] = (skippedByIntercept.reasons[typeMeta.label] || 0) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const sendAt = formatDateTime(row.send_time);
|
const sendAt = formatDateTime(row.send_time);
|
||||||
const logisticAt = formatDateTime(row.logistic_time);
|
const logisticAt = formatDateTime(row.logistic_time);
|
||||||
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
|
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
|
||||||
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
|
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
|
||||||
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
|
||||||
|
|
||||||
stat.exceptionOrders.add(oId);
|
stat.exceptionOrders.add(oId);
|
||||||
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
||||||
@ -387,11 +572,33 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
||||||
|
// 🔥 emptyWindow = 本批窗口里**一行都没取到**(12 仓范围)。
|
||||||
|
// 这时 snapshotAt 必然是空串,v1 的判据(coversDeadline=null、stale=false、hint='')
|
||||||
|
// 会让「上游没同步」和「当天真的没发货」长得一模一样,页面/钉钉都显示成 0 单全绿。
|
||||||
|
// 所以窗口为空时补查一次**表级**最后灌库时刻,把两种情形分开。
|
||||||
|
const emptyWindow = rows.length === 0;
|
||||||
|
const sourceLastUpdate = emptyWindow ? await loadSourceLastUpdate() : '';
|
||||||
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
||||||
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
||||||
|
const sourceAgeMinutes = sourceLastUpdate ? diffMinutes(sourceLastUpdate, generatedAt) : null;
|
||||||
|
const sourceStale = sourceAgeMinutes !== null && sourceAgeMinutes > SOURCE_STALE_MINUTES;
|
||||||
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
||||||
let hint = '';
|
let hint = '';
|
||||||
if (coversDeadline === false) {
|
if (emptyWindow) {
|
||||||
|
const lastText = sourceLastUpdate || '';
|
||||||
|
const hours = sourceAgeMinutes === null ? 0 : Math.round((sourceAgeMinutes / 60) * 10) / 10;
|
||||||
|
if (sourceStale) {
|
||||||
|
hint = `${batch.shipDate} 的发货记录在物流镜像表里一条都没有 —— 该表最后更新 ${lastText}(已 ${hours} 小时未同步)。`
|
||||||
|
+ ' 上面的「0 单 / 0 个包裹」是数据没进库,不代表当天没发货。'
|
||||||
|
+ ' 请先到聚水潭核对当天发货量,等上游同步后再刷新本页。';
|
||||||
|
} else if (lastText) {
|
||||||
|
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录(该表最后更新 ${lastText},数据源本身正常)。`
|
||||||
|
+ ' 若当天确实有发货,说明上游漏同步了这一天,请到聚水潭核对;若当天本就停发,这个 0 才是真的。';
|
||||||
|
} else {
|
||||||
|
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录,且取不到该表最后更新时间 ——`
|
||||||
|
+ ' 分不清是「当天没发货」还是「数据没同步」,请到聚水潭核对后再看本页。';
|
||||||
|
}
|
||||||
|
} else if (coversDeadline === false) {
|
||||||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
||||||
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
||||||
} else if (stale) {
|
} else if (stale) {
|
||||||
@ -420,15 +627,30 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
nonRealPackages: skippedByType.packages,
|
nonRealPackages: skippedByType.packages,
|
||||||
nonRealOrders: skippedByType.orders.size,
|
nonRealOrders: skippedByType.orders.size,
|
||||||
nonRealReasons: skippedByType.reasons,
|
nonRealReasons: skippedByType.reasons,
|
||||||
|
// 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母
|
||||||
|
interceptPackages: skippedByIntercept.packages,
|
||||||
|
interceptOrders: skippedByIntercept.orders.size,
|
||||||
|
interceptReasons: skippedByIntercept.reasons,
|
||||||
|
// 转仓代发(转x00005)—— 整单不计:既不算异常,也不进原仓发货分母
|
||||||
|
transferPackages: skippedByTransfer.packages,
|
||||||
|
transferOrders: skippedByTransfer.orders.size,
|
||||||
|
transferReasons: skippedByTransfer.reasons,
|
||||||
},
|
},
|
||||||
orders: exceptions.slice(0, limit),
|
// 缓存里存全量,limit 由 finalizeDetail 在出口截断
|
||||||
|
orders: exceptions,
|
||||||
detailLimit: limit,
|
detailLimit: limit,
|
||||||
truncated: exceptions.length > limit,
|
truncated: false,
|
||||||
freshness: {
|
freshness: {
|
||||||
snapshotAt,
|
snapshotAt,
|
||||||
ageMinutes,
|
ageMinutes,
|
||||||
stale,
|
stale,
|
||||||
coversDeadline,
|
coversDeadline,
|
||||||
|
// 本批窗口 0 行:0 单含义不明,必须配合下面两个字段读
|
||||||
|
emptyWindow,
|
||||||
|
// 表级最后灌库时刻(只有 emptyWindow 时才探测,否则为空串)
|
||||||
|
sourceLastUpdate,
|
||||||
|
sourceAgeMinutes,
|
||||||
|
sourceStale,
|
||||||
hint,
|
hint,
|
||||||
},
|
},
|
||||||
generatedAt,
|
generatedAt,
|
||||||
@ -446,7 +668,7 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
}
|
}
|
||||||
if (oldestKey) resultCache.delete(oldestKey);
|
if (oldestKey) resultCache.delete(oldestKey);
|
||||||
}
|
}
|
||||||
return payload;
|
return finalizeDetail(payload, limit, false, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
373
backend/src/services/warehouseShipTimelinessNotifySettings.js
Normal file
373
backend/src/services/warehouseShipTimelinessNotifySettings.js
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「仓储中心发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 业务口径(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,
|
||||||
|
};
|
||||||
@ -1,10 +1,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
* 仓储中心发货时效(2026-10-20 新增)
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
* 需求(yanhui 2026-10-20):
|
* 需求(yanhui 2026-10-20):
|
||||||
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
* MKH-BOM 列表左侧加一栏「仓储中心发货时效」,下列 11 个仓:
|
||||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||||
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
||||||
@ -32,7 +32,10 @@
|
|||||||
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||||
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||||
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
||||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
|
* 🔴 2026-10-28 现状:候选池取订单两表的 `send_date`,起算点取状态表的
|
||||||
|
* `last_oa_confirm_time`(审核时间),**下面这段「创建时间 = 出库单 created」
|
||||||
|
* 是老口径,仅作历史记录,不要再照着实现**。
|
||||||
|
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)(已废弃)
|
||||||
* ⚠️ 不是 `order_date`(平台下单时间);
|
* ⚠️ 不是 `order_date`(平台下单时间);
|
||||||
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
||||||
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
||||||
@ -262,6 +265,48 @@ const OUTBOUND_TABLES = Object.freeze({
|
|||||||
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
|
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
|
||||||
const ORDER_STATE_TABLE = 'jst_order_express_details';
|
const ORDER_STATE_TABLE = 'jst_order_express_details';
|
||||||
|
|
||||||
|
// ── 🔥 2026-10-28 数据源切换:主数据源改成「订单 + 订单明细」────────────────
|
||||||
|
// 业务 yanhui 2026-10-28 明确指出:「核验抓取的销售订单表是错误的,应该抓
|
||||||
|
// `jst_Orders`(订单)+ `jst_OrderItem`(订单明细),o_id → o_id;审核时间就是
|
||||||
|
// jst_Orders 里的创建时间」。
|
||||||
|
//
|
||||||
|
// 为什么原来那张状态镜像表不能用(实测证据):
|
||||||
|
// ① `send_time` 会**漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里,
|
||||||
|
// 订单表已有 `send_date` 的 **563 单**、status 已是 Sent 的 492 单 ——
|
||||||
|
// 明明发了却还算超时;2026-10-28 06h/07h 那批大批量刷新后集中爆发。
|
||||||
|
// ② 状态表对**被合并 / 拆单父单**不回写(已有 89 + 755 单),必须回订单表看 status。
|
||||||
|
// ③ 状态表会**整批停更**:2026-10-27 11:54:59 之后 6.8 小时零写入,
|
||||||
|
// 18:00 轮推出去 34 单里混着已发货的(业务当场发现)。
|
||||||
|
// ④ 两张表发货时间**其实同源**:状态表 send_time 与订单表 send_date
|
||||||
|
// 27387 单逐条比对**零偏差** ⟹ 订单表才是权威,状态表只是它的滞后副本。
|
||||||
|
//
|
||||||
|
// ⟹ 主查询直接打订单两表(店铺互斥必须都读),**不再依赖状态表**。
|
||||||
|
// 仓库归属用订单表的 `wms_co_id`(不是状态表的仓名文本),更准。
|
||||||
|
// 状态表只剩「售后已作废」一个判据,实测在「发货中+未发」里只影响 4 单,可放弃。
|
||||||
|
const ORDER_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||||
|
|
||||||
|
// 订单表 `status` 的取值(聚水潭实时状态,不是下单快照 —— 这一点
|
||||||
|
// `jst_Orders.status` 与代码历史上「status 不可信」的结论不同,见下面 CREATED 段说明):
|
||||||
|
// Delivering 发货中 —— **唯一该考核仓库的状态**
|
||||||
|
// WaitConfirm 待审核 —— 仓库还没接到单,单列「待审核」不计超时
|
||||||
|
// Question 疑问/异常 —— 单列「异常待处理」不计超时
|
||||||
|
// Sent 已发货 → 剔除
|
||||||
|
// Cancelled 已取消(业务叫「作废」)→ 剔除
|
||||||
|
// Split / Merged 拆单父单 / 被合并单 → 剔除(自己永不发货)
|
||||||
|
// WaitOuterSent 等供销商|外仓发货 → 剔除(不是本仓发)
|
||||||
|
// 近 30 天分布:Sent 132341 / Cancelled 66643 / Split 8251 / Question 2057 /
|
||||||
|
// Merged 1854 / Delivering 1446 / WaitConfirm 190 / WaitOuterSent 45
|
||||||
|
const ORDER_PENDING_STATUS = 'Delivering';
|
||||||
|
const ORDER_UNAUDITED_STATUS = 'WaitConfirm';
|
||||||
|
const ORDER_QUESTION_STATUS = 'Question';
|
||||||
|
const ORDER_SHIPPED_STATUS = 'Sent';
|
||||||
|
// 主查询只捞这三种(其余状态一律不进候选,SQL 侧就过滤掉,不传回 Node)
|
||||||
|
const ORDER_CANDIDATE_STATUSES = Object.freeze([
|
||||||
|
ORDER_PENDING_STATUS,
|
||||||
|
ORDER_UNAUDITED_STATUS,
|
||||||
|
ORDER_QUESTION_STATUS,
|
||||||
|
]);
|
||||||
|
|
||||||
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
|
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
|
||||||
const SHIPPING_STATUS = '发货中';
|
const SHIPPING_STATUS = '发货中';
|
||||||
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
|
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
|
||||||
@ -276,24 +321,26 @@ const ABNORMAL_STATUS = '异常';
|
|||||||
// 漏掉这个排除,整个页面会被作废单淹没。
|
// 漏掉这个排除,整个页面会被作废单淹没。
|
||||||
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
||||||
|
|
||||||
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
|
// ── 起算点:聚水潭「审核时间」`last_oa_confirm_time` ────────────────────────
|
||||||
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
|
// 🔥 2026-10-28 **下午二次纠偏**(业务 yanhui 拿 9837824 打回来):
|
||||||
|
// 起算点是**审核时间**,不是订单创建时间。证据:
|
||||||
|
// 9837824 created 10-19 23:44:22 → last_oa_confirm_time 10-28 09:58:42,
|
||||||
|
// 积压 8 天半;仓库 09:58 审核、10:09:55 快递员就上门取件。
|
||||||
|
// 按 created 算「超时 205 小时」= 把客服审核积压算到仓库头上。
|
||||||
|
// 量化(10-28 13:00 轮 599 单):376 单是当天 08:10~10:30 才批量审核的,
|
||||||
|
// 按审核时间分轮后 13:00 轮只剩 247(再剔除漏回写的已发单 → 138)。
|
||||||
//
|
//
|
||||||
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补:
|
// ⚠️ 上午那版注释里「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟,
|
||||||
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。
|
// 业务要求按创建时间算」已被推翻 —— 那句话只统计了当期正常单,
|
||||||
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51),
|
// 长尾积压单能差 8~11 天。**别再把起算点改回 created。**
|
||||||
// 是**同步落库时间**,用了会把起算点整体推后几小时。
|
|
||||||
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
|
|
||||||
// 要换掉。
|
|
||||||
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
|
|
||||||
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
|
|
||||||
// 这才是聚水潭订单列表里「创建时间」那一列。
|
|
||||||
//
|
//
|
||||||
// 性能(2026-10-20 实测 9,676 个候选):
|
// 审核时间只存在于状态镜像表 `jst_order_express_details`(订单两表没有这个字段),
|
||||||
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。
|
// 由 service 层按 `inner_order_id IN (...)` 批量取;取不到时回退 created 并标 `*`。
|
||||||
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。
|
// 用 last 而不是 first:业务认为改单重审后重新进作业流,从最后一次审核计时
|
||||||
|
// (代价是重复审核会冲掉已累计超时,业务已知悉)。
|
||||||
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||||
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
||||||
|
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。
|
||||||
const CREATED_BACKFILL_BATCH = 200;
|
const CREATED_BACKFILL_BATCH = 200;
|
||||||
|
|
||||||
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
|
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
|
||||||
@ -307,8 +354,88 @@ const CREATED_BACKFILL_BATCH = 200;
|
|||||||
// 单子早随主单发走了,状态表还停在「发货中」。
|
// 单子早随主单发走了,状态表还停在「发货中」。
|
||||||
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
||||||
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
||||||
|
// 🔥 2026-10-22 补 `Question` —— **但不能塞进这个数组**:
|
||||||
|
// 这个数组在「状态分类之前」就判定,会把状态表「异常」的单(订单表常是 Question)
|
||||||
|
// 从「异常待处理」清单里一起吞掉(实测 heldCount 472 → 10,少掉 462 单)。
|
||||||
|
// ⟹ Question 单独在 `state === '发货中'` 之后判,见 VOID_QUESTION_STATUS。
|
||||||
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
||||||
|
|
||||||
|
// 订单表 status=Question(聚水潭后台显示「已作废/问题单」)—— 业务 2026-10-22 确认:
|
||||||
|
// so_id 3655421011854634(9823980) / 3658479011191285(9880810) 在后台都已作废,
|
||||||
|
// 而状态表 order_status_name 仍停在「发货中」+ send_time 空、售后「暂无售后」
|
||||||
|
// ⟹ 上游状态没同步,只信状态表判不出来。仅在「发货中」分支生效,不影响异常清单。
|
||||||
|
const VOID_QUESTION_STATUS = 'Question';
|
||||||
|
|
||||||
|
// 订单表 `labels` 里出现这些关键词,等价于「客户发货前退款/取消」= 作废,不考核仓库。
|
||||||
|
// 实测 9823980 的 labels = 「发货前售后,大单待确认,店铺-京东,晚发赔,禁止拆分」;
|
||||||
|
// 已取消单(order_status_name='已取消')几乎全都带这个标签,可作交叉验证。
|
||||||
|
// ⚠️ `labels` 是长文本列,回订单表补它会让 backfill 变慢(见 service 里的投影说明),
|
||||||
|
// 所以只投影一个 `CASE WHEN ... THEN 1 ELSE 0 END` 布尔列,不把原文传回来。
|
||||||
|
const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
||||||
|
|
||||||
|
// 状态表 `aftersale_status_name` 的「已作废」—— 售后维度最权威的作废标记。
|
||||||
|
// 目前带这个值的单在订单表基本都是 Sent(已被其它判据剔除),这里是防御性兜底:
|
||||||
|
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
||||||
|
const AFTERSALE_VOID_STATUS = '已作废';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态镜像表 `jst_order_express_details.order_status_name` 的**终态**。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-29 血的教训(业务第三次报「作废单/已发单还在考核里」):
|
||||||
|
* 订单两表**会各自停更**(当天 qmcust 停在 07:25、jst_Orders 停在 12:45),
|
||||||
|
* 停更期间在聚水潭做的「取消 / 发货」动作,订单表里根本看不到 ——
|
||||||
|
* 订单 status 仍是 `Delivering`、`send_date` 仍是空,于是被当成真超时。
|
||||||
|
* 状态镜像表虽然也会停(当天 11:52),但**比订单表新鲜 4 小时**,
|
||||||
|
* 而且 `order_status_name` 是聚水潭订单状态的直接映射。
|
||||||
|
*
|
||||||
|
* 实测(2026-10-29 13:00 轮 159 单):
|
||||||
|
* · `order_status_name='已取消'` 11 单 —— 订单表的 `labels` 里**没有**「发货前售后」
|
||||||
|
* 这个关键词(`presaleVoid` 判据完全失效),状态表的 `order_labels` 才有,且带
|
||||||
|
* 「已打印被取消」。业务就是拿这 11 单说「作废的也在考核之内」。
|
||||||
|
* · `order_status_name='已发货'` 0 单 —— 机制上必须有,否则停更期发的货永远判不出。
|
||||||
|
*
|
||||||
|
* ⚠️ **`aftersale_status_name='已作废'` 不是作废单**!那是「售后单被撤销」,
|
||||||
|
* 订单照常发货、**仍然要考核**(近 2 天 1387 单都是这种)。别把两个混为一谈,
|
||||||
|
* 能判「这单不用发了」的只有 `order_status_name='已取消'`。
|
||||||
|
*/
|
||||||
|
const STATE_ORDER_VOID_STATUS = '已取消';
|
||||||
|
const STATE_ORDER_SHIPPED_STATUS = '已发货';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销售出库单 `status` 里**等于单据作废**的值。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-29 新增(业务点名 9992097 等 9 单:「在聚水潭的销售出库单里找一下这个作废单据」)。
|
||||||
|
*
|
||||||
|
* 聚水潭的作废会落在**出库单**上:`jst_salesOutboundInfo.status` / `jst_qmcust_salesoutbound.status`
|
||||||
|
* 取值只有四种(近 2 天实测):`Confirmed` / `WaitConfirm` / `Delete` / `OuterConfirming`,
|
||||||
|
* 其中 `Delete` 就是单据作废。
|
||||||
|
*
|
||||||
|
* 为什么必须加这一条判据(状态表 `order_status_name='已取消'` 不够):
|
||||||
|
* · 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`、36 单是 `Question`**,
|
||||||
|
* 这些单状态镜像表未必抓得到(它一天只抓几次),只能靠出库单判;
|
||||||
|
* · 出库单是仓库侧最贴身的单据,作废动作一定先落在这里。
|
||||||
|
*
|
||||||
|
* ⚠️ **不能只看「有 Delete 就算作废」**:一个订单可能有多张出库单(拆包 / 重开),
|
||||||
|
* 删掉旧的那张、新的还在 `WaitConfirm` —— 这种单照常要发。
|
||||||
|
* ⟹ 判据是「**该订单没有任何非 Delete 的出库单**」,见 service 里 ioAllDeleted 的说明。
|
||||||
|
*
|
||||||
|
* ⚠️ 也**不能**用「没有出库单」当判据:近 3 天未发单里 **82%(1467/1790)本来就没有出库单**
|
||||||
|
* (订单还没推下游),拿它判作废会把绝大多数正常待发单全放跑。
|
||||||
|
*/
|
||||||
|
const IO_VOID_STATUS = 'Delete';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态镜像表 `logistic_status_name` 里**等于已发**的值。
|
||||||
|
*
|
||||||
|
* `拒收` 也算已发 —— 快递拿走过了,客户拒收是后续环节,仓库的发货动作已完成。
|
||||||
|
* ⚠️ **`无物流单` 不算**(39/159 单是这种:单子还没录快递单,可能自提/待定),
|
||||||
|
* `未揽收` 也不算(只是打了单,快递还没来取)。
|
||||||
|
* ⚠️ 别拿 `logistic_time` 当发货时间:它多数是「快递员将上门取件」的预告。
|
||||||
|
*/
|
||||||
|
const STATE_LOGISTIC_SHIPPED_STATUSES = Object.freeze([
|
||||||
|
'已揽收', '运输中', '派送中', '已签收', '拒收',
|
||||||
|
]);
|
||||||
|
|
||||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||||
@ -417,12 +544,29 @@ async function loadCenterWarehouseMap(force = false) {
|
|||||||
warehouseCache = {
|
warehouseCache = {
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
rows: list,
|
rows: list,
|
||||||
|
// 全量「wms_co_id → 仓名」映射(**含归不到主仓的仓**)。
|
||||||
|
// 发货时效主查询不再 LEFT JOIN 仓表(实测 JOIN 会让执行计划从 28s 恶化到 61~105s),
|
||||||
|
// 改为在主查询里取回 wms_co_id、在 Node 侧用这份缓存换名字。
|
||||||
|
allNames: rows.map((r) => ({ wmsCoId: Number(r.wms_co_id), name: String(r.name || '') })),
|
||||||
|
unmatched,
|
||||||
unmatchedCount: unmatched.length,
|
unmatchedCount: unmatched.length,
|
||||||
excluded,
|
excluded,
|
||||||
};
|
};
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 全量「wms_co_id → 仓名」映射(含归不到主仓的仓),供 service 侧做仓名还原 */
|
||||||
|
async function loadWarehouseNameMap() {
|
||||||
|
if (Date.now() - warehouseCache.at >= WAREHOUSE_CACHE_TTL_MS || !warehouseCache.rows.length) {
|
||||||
|
await loadCenterWarehouseMap();
|
||||||
|
}
|
||||||
|
const map = new Map();
|
||||||
|
for (const r of warehouseCache.allNames || []) {
|
||||||
|
if (Number.isFinite(r.wmsCoId)) map.set(r.wmsCoId, r.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
function clearWarehouseCache() {
|
function clearWarehouseCache() {
|
||||||
warehouseCache = { at: 0, rows: [] };
|
warehouseCache = { at: 0, rows: [] };
|
||||||
}
|
}
|
||||||
@ -535,6 +679,7 @@ module.exports = {
|
|||||||
centerWindowEnd,
|
centerWindowEnd,
|
||||||
nextRoundDeadline,
|
nextRoundDeadline,
|
||||||
loadCenterWarehouseMap,
|
loadCenterWarehouseMap,
|
||||||
|
loadWarehouseNameMap,
|
||||||
clearWarehouseCache,
|
clearWarehouseCache,
|
||||||
getWarehouseCache: () => warehouseCache,
|
getWarehouseCache: () => warehouseCache,
|
||||||
resolveActiveRound,
|
resolveActiveRound,
|
||||||
@ -547,6 +692,13 @@ module.exports = {
|
|||||||
NOT_SPLIT_MERGED_SQL,
|
NOT_SPLIT_MERGED_SQL,
|
||||||
OUTBOUND_TABLES,
|
OUTBOUND_TABLES,
|
||||||
ORDER_STATE_TABLE,
|
ORDER_STATE_TABLE,
|
||||||
|
// 2026-10-28 起主数据源 = 订单两表(状态表退居兜底)
|
||||||
|
ORDER_SOURCE_TABLES,
|
||||||
|
ORDER_PENDING_STATUS,
|
||||||
|
ORDER_UNAUDITED_STATUS,
|
||||||
|
ORDER_QUESTION_STATUS,
|
||||||
|
ORDER_SHIPPED_STATUS,
|
||||||
|
ORDER_CANDIDATE_STATUSES,
|
||||||
SHIPPING_STATUS,
|
SHIPPING_STATUS,
|
||||||
UNAUDITED_STATUS,
|
UNAUDITED_STATUS,
|
||||||
ABNORMAL_STATUS,
|
ABNORMAL_STATUS,
|
||||||
@ -554,6 +706,13 @@ module.exports = {
|
|||||||
CREATED_SOURCE_TABLES,
|
CREATED_SOURCE_TABLES,
|
||||||
CREATED_BACKFILL_BATCH,
|
CREATED_BACKFILL_BATCH,
|
||||||
VOID_ORDER_STATUS,
|
VOID_ORDER_STATUS,
|
||||||
|
VOID_QUESTION_STATUS,
|
||||||
|
VOID_ORDER_LABEL_KEYWORDS,
|
||||||
|
AFTERSALE_VOID_STATUS,
|
||||||
|
STATE_ORDER_VOID_STATUS,
|
||||||
|
STATE_ORDER_SHIPPED_STATUS,
|
||||||
|
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||||
|
IO_VOID_STATUS,
|
||||||
SHIP_TIMELINESS_HOURS,
|
SHIP_TIMELINESS_HOURS,
|
||||||
toNumber,
|
toNumber,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -395,8 +395,12 @@ test('replenish: products without sales stay "no sale" even with a pending work
|
|||||||
|
|
||||||
test('pending work order filters keep the business-confirmed source and exclusions', () => {
|
test('pending work order filters keep the business-confirmed source and exclusions', () => {
|
||||||
assert.equal(_private.WORK_ORDER_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓');
|
assert.equal(_private.WORK_ORDER_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓');
|
||||||
assert.equal(_private.WORK_ORDER_VOID_STATUS, '作废');
|
// 2026-11-11 换源:聚水潭原生加工单三表(po_id 关联),状态值域为英文
|
||||||
assert.equal(_private.WORK_ORDER_RECEIVED_STATUS, '全部入库');
|
assert.equal(_private.WORK_ORDER_WMS_CO_ID, 15718196);
|
||||||
|
assert.equal(_private.WORK_ORDER_VOID_STATUS, 'Cancelled');
|
||||||
|
assert.equal(_private.WORK_ORDER_RECEIVED_STATUS, 'Finished');
|
||||||
|
assert.equal(_private.WORK_ORDER_INFO_TABLE, 'rpt.jst_manufactureInfo');
|
||||||
|
assert.equal(_private.WORK_ORDER_ITEM_TABLE, 'rpt.jst_manufactureItem');
|
||||||
assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产');
|
assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -144,19 +144,29 @@ test('状态为空时按运单号区分:有号=未揽收,无号=无物流单
|
|||||||
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
|
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
|
||||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||||
|
|
||||||
function stubDb(rows) {
|
function stubDb(rows, options = {}) {
|
||||||
const calls = { times: 0, sql: [], params: [] };
|
const calls = { times: 0, sql: [], params: [], options: [], api: [] };
|
||||||
const handler = async (sql, params) => {
|
const makeHandler = (api) => async (sql, params, opts) => {
|
||||||
calls.times += 1;
|
calls.times += 1;
|
||||||
calls.sql.push(String(sql));
|
calls.sql.push(String(sql));
|
||||||
calls.params.push(params || {});
|
calls.params.push(params || {});
|
||||||
|
calls.options.push(opts || {});
|
||||||
|
calls.api.push(api);
|
||||||
|
// 售后拦截判定会额外查两张售后表 —— 按表名分流,别把物流行当成售后行
|
||||||
|
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
||||||
|
return options.interceptRows || [];
|
||||||
|
}
|
||||||
|
// 窗口 0 行时的「表级最后灌库时刻」探测
|
||||||
|
if (/MAX\(\s*e\.query_time\s*\)/iu.test(String(sql))) {
|
||||||
|
return options.sourceMaxQueryTime === undefined ? [] : [{ max_q: options.sourceMaxQueryTime }];
|
||||||
|
}
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
require.cache[dbPath] = {
|
require.cache[dbPath] = {
|
||||||
id: dbPath,
|
id: dbPath,
|
||||||
filename: dbPath,
|
filename: dbPath,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
exports: { query: handler, querySlow: handler },
|
exports: { query: makeHandler('query'), querySlow: makeHandler('querySlow') },
|
||||||
};
|
};
|
||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
@ -206,9 +216,9 @@ const rows = [
|
|||||||
send_time: '2026-10-20 11:00:00' }),
|
send_time: '2026-10-20 11:00:00' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
async function withStub(fn, stubRows = rows) {
|
async function withStub(fn, stubRows = rows, options = {}) {
|
||||||
const realDb = require.cache[dbPath];
|
const realDb = require.cache[dbPath];
|
||||||
const calls = stubDb(stubRows);
|
const calls = stubDb(stubRows, options);
|
||||||
delete require.cache[svcPath];
|
delete require.cache[svcPath];
|
||||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||||
try {
|
try {
|
||||||
@ -276,18 +286,20 @@ test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async
|
|||||||
|
|
||||||
test('结果缓存生效,force=1 绕过缓存重查', async () => {
|
test('结果缓存生效,force=1 绕过缓存重查', async () => {
|
||||||
await withStub(async (svc, calls) => {
|
await withStub(async (svc, calls) => {
|
||||||
|
// 只数主查询:售后拦截判定会额外查两张售后表,别把它们的次数算进来
|
||||||
|
const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length;
|
||||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||||
const first = await svc.getLogisticsExceptions(opts);
|
const first = await svc.getLogisticsExceptions(opts);
|
||||||
assert.equal(first.cached, false);
|
assert.equal(first.cached, false);
|
||||||
assert.equal(calls.times, 1);
|
assert.equal(mainQueries(), 1);
|
||||||
|
|
||||||
const second = await svc.getLogisticsExceptions(opts);
|
const second = await svc.getLogisticsExceptions(opts);
|
||||||
assert.equal(second.cached, true);
|
assert.equal(second.cached, true);
|
||||||
assert.equal(calls.times, 1, '第二次应命中缓存');
|
assert.equal(mainQueries(), 1, '第二次应命中缓存');
|
||||||
|
|
||||||
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
|
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
|
||||||
assert.equal(third.cached, false);
|
assert.equal(third.cached, false);
|
||||||
assert.equal(calls.times, 2, 'force=1 必须真查库');
|
assert.equal(mainQueries(), 2, 'force=1 必须真查库');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -320,19 +332,39 @@ test('导出:放开明细上限、返回全量异常(不受列表 2000 上
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => {
|
test('缓存只按核验日:页面加载后导出直接命中,不再重算整轮', async () => {
|
||||||
await withStub(async (svc, calls) => {
|
await withStub(async (svc, calls) => {
|
||||||
|
const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length;
|
||||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||||
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
|
||||||
assert.equal(calls.times, 1);
|
|
||||||
|
|
||||||
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
|
const list = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||||
await svc.getLogisticsExceptionExportRows(opts);
|
assert.equal(list.cached, false, '页面首次应查库');
|
||||||
assert.equal(calls.times, 2, '导出是首次查,应真查库');
|
assert.equal(mainQueries(), 1);
|
||||||
|
|
||||||
|
// 导出 limit=5000 与页面 1000 不同,但扫库成本一样 → 必须复用同一份全量缓存
|
||||||
|
// (2026-10-23 修:以前键里带 limit,这里会再扫一遍,生产实测多花 317s)
|
||||||
|
const exp = await svc.getLogisticsExceptionExportRows(opts);
|
||||||
|
assert.equal(mainQueries(), 1, '导出不该重新扫库');
|
||||||
|
assert.equal(exp.detailLimit, 5000);
|
||||||
|
assert.equal(exp.rows.length, list.summary.exceptionPackages, '导出仍要拿到全部明细');
|
||||||
|
|
||||||
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||||
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
|
assert.equal(listAgain.cached, true, '页面那份缓存应仍在');
|
||||||
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
|
assert.equal(mainQueries(), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('缓存存全量:先按小 limit 取,后面大 limit 仍拿到完整明细', async () => {
|
||||||
|
await withStub(async (svc) => {
|
||||||
|
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||||
|
const small = await svc.getLogisticsExceptions({ ...opts, limit: 2 });
|
||||||
|
assert.equal(small.orders.length, 2);
|
||||||
|
assert.equal(small.truncated, true);
|
||||||
|
|
||||||
|
const big = await svc.getLogisticsExceptions({ ...opts, limit: 5000, maxLimit: 5000 });
|
||||||
|
assert.equal(big.cached, true, '应命中同一份缓存');
|
||||||
|
assert.equal(big.orders.length, big.summary.exceptionPackages, '缓存不能只存被截断的那 2 条');
|
||||||
|
assert.equal(big.truncated, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -516,6 +548,45 @@ test('刷单的包裹不进分母也不算异常', async () => {
|
|||||||
}, mixed);
|
}, mixed);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('转仓标签识别:转x00005(含大小写/空格)都认,转空包不认', () => {
|
||||||
|
assert.equal(registry.matchTransferWarehouse('["京喜","转x00005"]'), '转x00005');
|
||||||
|
assert.equal(registry.matchTransferWarehouse('["转X00005"]'), '转X00005');
|
||||||
|
assert.equal(registry.matchTransferWarehouse('["转 x00005"]'), '转x00005');
|
||||||
|
// 后续可能出现别的目标仓编码
|
||||||
|
assert.equal(registry.matchTransferWarehouse('["转x00006"]'), '转x00006');
|
||||||
|
// 反例:转空包是信誉单(发空包),不是转仓代发
|
||||||
|
assert.equal(registry.matchTransferWarehouse('["转空包发货"]'), null);
|
||||||
|
assert.equal(registry.matchTransferWarehouse('["京喜","线上发货"]'), null);
|
||||||
|
assert.equal(registry.matchTransferWarehouse(''), null);
|
||||||
|
assert.equal(registry.matchTransferWarehouse(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('转仓代发整单不计:不算异常,也不进原仓发货分母', async () => {
|
||||||
|
const mixed = [
|
||||||
|
// 30:转x00005 · 未揽收 → 崔桥下单、曹县代发,货不是从原仓走的,整单不计
|
||||||
|
mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '未揽收', logistic_time: null,
|
||||||
|
logistic_company_name: '曹县韵达小件', order_labels: '["京喜","转x00005"]' }),
|
||||||
|
// 31:同样「未揽收」但没转仓标签 → 必须留下
|
||||||
|
mk({ inner_order_id: 31, so_id: 'SO-31', logistic_status_name: '未揽收', logistic_time: null,
|
||||||
|
order_labels: '["京喜"]' }),
|
||||||
|
];
|
||||||
|
await withStub(async (svc) => {
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||||
|
assert.deepEqual(data.orders.map((o) => o.oId), ['31'], '只留非转仓那单');
|
||||||
|
assert.equal(data.exclusions.transferOrders, 1);
|
||||||
|
assert.equal(data.exclusions.transferPackages, 1);
|
||||||
|
assert.equal(data.exclusions.transferReasons['转x00005'], 1);
|
||||||
|
assert.equal(data.summary.exceptionPackages, 1, '转仓单不计异常');
|
||||||
|
// 🔥 业务 2026-10-24:货不从原仓走,原仓发货量也不该算
|
||||||
|
assert.equal(data.summary.packageCount, 1, '转仓单不进原仓发货分母');
|
||||||
|
assert.equal(data.summary.shippedOrders, 1, '转仓单不算原仓发货单');
|
||||||
|
// 仓维度:崔桥仓发货单数也只剩 31 那单(30 是转仓、不计)
|
||||||
|
const center = data.centers.find((c) => c.key === 'CQ');
|
||||||
|
assert.equal(center.shippedOrders, 1, '崔桥仓发货单数不应包含转仓单');
|
||||||
|
assert.equal(center.exceptionOrders, 1, '崔桥仓异常只算 31');
|
||||||
|
}, mixed);
|
||||||
|
});
|
||||||
|
|
||||||
test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
||||||
await withStub(async (svc) => {
|
await withStub(async (svc) => {
|
||||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
|
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
|
||||||
@ -523,5 +594,168 @@ test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
|||||||
assert.ok(Array.isArray(data.exclusions.carriers));
|
assert.ok(Array.isArray(data.exclusions.carriers));
|
||||||
assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
|
assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
|
||||||
assert.equal(data.exclusions.carrierOrders, 0);
|
assert.equal(data.exclusions.carrierOrders, 0);
|
||||||
|
assert.equal(data.exclusions.interceptOrders, 0, '未开跑也要给出拦截剔除字段');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── ⑦ 现场拦截(售后已签收)不算转运异常(业务 2026-10-23)─────────────
|
||||||
|
test('现场拦截判据:签收单号非空 + 实退数量>0 才剔,货还在路上的不剔', async () => {
|
||||||
|
const mixed = [
|
||||||
|
// 30:已揽收未中转 → 售后单有签收单号 + 实退 1 件 → 已签收,必须剔
|
||||||
|
mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '已揽收',
|
||||||
|
logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00',
|
||||||
|
last_info: '中通快递 已收取快件' }),
|
||||||
|
// 31:同形态但售后 r_qty=0(货还在路上)→ **不能剔**,仍是真异常
|
||||||
|
mk({ inner_order_id: 31, so_id: 'SO-31', logistic_status_name: '已揽收',
|
||||||
|
logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00',
|
||||||
|
last_info: '中通快递 已收取快件' }),
|
||||||
|
// 32:未揽收 + 售后已签收 → 同样剔(不挑异常类型)
|
||||||
|
mk({ inner_order_id: 32, so_id: 'SO-32', logistic_status_name: '未揽收', logistic_time: null }),
|
||||||
|
// 33:正常已签收 → 本来就不算异常,不受影响
|
||||||
|
mk({ inner_order_id: 33, so_id: 'SO-33' }),
|
||||||
|
];
|
||||||
|
await withStub(async (svc, calls) => {
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||||
|
|
||||||
|
// 30 / 32 被剔;31 仍是异常;33 本来正常
|
||||||
|
assert.deepEqual(data.orders.map((o) => o.oId), ['31']);
|
||||||
|
assert.equal(data.summary.exceptionOrders, 1);
|
||||||
|
// 🔥 发货分母不能掉:货确实发出去了,只是物流环节已结束
|
||||||
|
assert.equal(data.summary.shippedOrders, 4, '拦截单仍算发货分母,否则异常率被放大');
|
||||||
|
assert.equal(data.summary.exceptionRate, 25, '1 / 4');
|
||||||
|
|
||||||
|
assert.equal(data.exclusions.interceptPackages, 2);
|
||||||
|
assert.equal(data.exclusions.interceptOrders, 2);
|
||||||
|
assert.equal(data.exclusions.interceptReasons['已揽收未中转'], 1);
|
||||||
|
assert.equal(data.exclusions.interceptReasons['未揽收'], 1);
|
||||||
|
|
||||||
|
// 判据 SQL:两张店铺表都要查、都要 join 明细、都必须卡 l_id 与 r_qty
|
||||||
|
const asSql = calls.sql.filter((s) => /jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(s));
|
||||||
|
assert.equal(asSql.length, 2, `售后表要查两套(店铺互斥),实际 ${asSql.length}`);
|
||||||
|
assert.ok(asSql.some((s) => /jst_returnAndRefundItem/u.test(s)), '必须关联明细表拿实退数量');
|
||||||
|
assert.ok(asSql.some((s) => /jst_qmcust_refundlist_item/u.test(s)), '客服售后单也要关联明细表');
|
||||||
|
for (const s of asSql) {
|
||||||
|
assert.match(s, /l_id[\s\S]*<> ''/u, '必须有「签收单号非空」条件');
|
||||||
|
assert.match(s, /SUM\(ISNULL\(it\.r_qty, 0\)\) > 0/u, '必须有「实退数量 > 0」条件');
|
||||||
|
assert.doesNotMatch(s, /it\.qty[\s\S]*> 0/u, '不能拿申请数量 qty 当实退数量');
|
||||||
|
}
|
||||||
|
}, mixed, { interceptRows: [{ o_id: 30 }, { o_id: 32 }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('售后表查询失败时降级:不剔单、不炸页面', async () => {
|
||||||
|
const mixed = [
|
||||||
|
mk({ inner_order_id: 40, so_id: 'SO-40', logistic_status_name: '未揽收', logistic_time: null }),
|
||||||
|
];
|
||||||
|
const realDb = require.cache[dbPath];
|
||||||
|
const calls = { times: 0, sql: [] };
|
||||||
|
require.cache[dbPath] = {
|
||||||
|
id: dbPath,
|
||||||
|
filename: dbPath,
|
||||||
|
loaded: true,
|
||||||
|
exports: {
|
||||||
|
query: async (sql) => {
|
||||||
|
calls.sql.push(String(sql));
|
||||||
|
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
||||||
|
throw new Error('售后表超时');
|
||||||
|
}
|
||||||
|
return mixed;
|
||||||
|
},
|
||||||
|
querySlow: async () => mixed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
delete require.cache[svcPath];
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||||
|
const svc = require(svcPath);
|
||||||
|
svc.clearLogisticsExceptionCache();
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||||
|
assert.equal(data.summary.exceptionOrders, 1, '售后表挂了就该照常报异常,别把异常吞掉');
|
||||||
|
assert.equal(data.exclusions.interceptOrders, 0);
|
||||||
|
} finally {
|
||||||
|
if (realDb) require.cache[dbPath] = realDb;
|
||||||
|
else delete require.cache[dbPath];
|
||||||
|
delete require.cache[svcPath];
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ⑧ 窗口 0 行:0 单不能被当成「当天没发货 / 全部正常」(2026-11-02)──────
|
||||||
|
// 起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
|
||||||
|
// 实况是镜像表 11-01 07:55 之后就没再灌库,11-01 的 12 仓发货记录一条都没进库。
|
||||||
|
test('窗口 0 行 + 数据源停更 → emptyWindow/sourceStale,hint 明说「数据没进库」', async () => {
|
||||||
|
const nov2 = new Date(2026, 10, 2, 10, 0, 0); // 2026-11-02 10:00(本机时区)
|
||||||
|
await withStub(async (svc, calls) => {
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||||
|
assert.equal(data.summary.shippedOrders, 0);
|
||||||
|
assert.equal(data.freshness.emptyWindow, true);
|
||||||
|
assert.equal(data.freshness.sourceLastUpdate, '2026-11-01 07:55:04');
|
||||||
|
assert.ok(data.freshness.sourceAgeMinutes > 120, `应算出停更小时数,实际 ${data.freshness.sourceAgeMinutes}`);
|
||||||
|
assert.equal(data.freshness.sourceStale, true);
|
||||||
|
assert.match(data.freshness.hint, /一条都没有/);
|
||||||
|
assert.match(data.freshness.hint, /不代表当天没发货/);
|
||||||
|
// 只在空窗口才付那次全表 MAX(query_time)(实测 13.5s)
|
||||||
|
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 1);
|
||||||
|
}, [], { sourceMaxQueryTime: '2026-11-01 07:55:04' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('窗口 0 行但数据源是新的 → 仍然提示「0 条记录」,不能静默全绿', async () => {
|
||||||
|
const nov2 = new Date(2026, 10, 2, 10, 0, 0);
|
||||||
|
// ⚠️ sourceLastUpdate 必须是「相对真实现在」的时间,不能像原来那样硬编码 2026-11-02 09:58。
|
||||||
|
// 服务层算 ageMinutes 用的是 registry.beijingNow()(真实时钟),不是测试传进来的 now ——
|
||||||
|
// 硬编码的日期过一天就变成「停更 24h」,这个用例第二天起就必挂(2026-11-14 实测挂在 main 上)。
|
||||||
|
const freshText = new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||||
|
}).format(new Date(Date.now() - 3 * 60 * 1000));
|
||||||
|
await withStub(async (svc) => {
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||||
|
assert.equal(data.freshness.emptyWindow, true);
|
||||||
|
assert.equal(data.freshness.sourceStale, false, '表是新的,不该报停更');
|
||||||
|
assert.match(data.freshness.hint, /0 条发货记录/, '真 0 单也要说清是「库里没有记录」');
|
||||||
|
assert.match(data.freshness.hint, /数据源本身正常/);
|
||||||
|
}, [], { sourceMaxQueryTime: freshText });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('窗口有行时不做全表探测(省掉 13.5s 的表级 MAX)', async () => {
|
||||||
|
await withStub(async (svc, calls) => {
|
||||||
|
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||||
|
assert.equal(data.freshness.emptyWindow, false);
|
||||||
|
assert.equal(data.freshness.sourceLastUpdate, '');
|
||||||
|
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ⑨ 超时参数必须「真生效」(2026-11-14)──────────────────────────────────
|
||||||
|
// 起因:11-13 09:10 那轮钉钉推送 300s 超时没推出去。查代码发现两处写了
|
||||||
|
// `{ timeoutMs: 240000 }` 想「给足 4 分钟」,而 cloudDatabase **根本不读 timeoutMs**
|
||||||
|
// (mssql 12.x 请求级 request.timeout 不生效,只能靠池配置级 requestTimeout,
|
||||||
|
// 共享池的池级超时创建时就定死了)—— 实际一路按 60s 掐断,
|
||||||
|
// 超时后 query() 还当可重试错误重连再查一次,日志里白等了约 2 分钟。
|
||||||
|
test('售后拦截查询走 querySlow + 20s 短超时 + 不重试', async () => {
|
||||||
|
await withStub(async (svc, calls) => {
|
||||||
|
await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||||
|
const idx = calls.sql.findIndex((s) => /jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(s));
|
||||||
|
assert.ok(idx >= 0, '这批有异常单,应该去查售后表');
|
||||||
|
assert.equal(calls.api[idx], 'querySlow', '必须走 querySlow:query() 的池级超时调用方改不了');
|
||||||
|
assert.equal(calls.options[idx].connectRequestTimeoutMs, 20000, '2 倍实测余量(8.5s → 20s)就够');
|
||||||
|
assert.equal(calls.options[idx].maxAttempts, 1, '超时就降级「不剔除」,别重连重试白等一轮');
|
||||||
|
assert.equal(calls.options[idx].timeoutMs, undefined, 'timeoutMs 是无效参数,不许再写');
|
||||||
|
}, rows, { interceptRows: [{ o_id: 1 }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空窗口的表级 MAX 探测也走 querySlow(共享池 60s 跑不动全表)', async () => {
|
||||||
|
const nov2 = new Date(2026, 10, 2, 10, 0, 0);
|
||||||
|
await withStub(async (svc, calls) => {
|
||||||
|
await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||||
|
const idx = calls.sql.findIndex((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s));
|
||||||
|
assert.ok(idx >= 0, '空窗口要探测表级最后灌库时刻');
|
||||||
|
assert.equal(calls.api[idx], 'querySlow');
|
||||||
|
assert.ok(
|
||||||
|
Number(calls.options[idx].connectRequestTimeoutMs) >= 120000,
|
||||||
|
`全表 MAX 实测已超 60s,必须放宽,实际 ${calls.options[idx].connectRequestTimeoutMs}`
|
||||||
|
);
|
||||||
|
assert.equal(calls.options[idx].maxAttempts, 1, '探测失败退回「不知道」即可,不值得重试');
|
||||||
|
assert.equal(calls.options[idx].timeoutMs, undefined);
|
||||||
|
}, [], { sourceMaxQueryTime: '2026-11-01 07:55:04' });
|
||||||
|
});
|
||||||
|
|||||||
@ -495,3 +495,249 @@ test('发文本:超长正文自动截断后仍会发出去(不是直接抛
|
|||||||
stub.restore();
|
stub.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── ⑤ 0 单不能用「全部正常」糊过去(2026-11-02)──────────────────────────
|
||||||
|
// 实况:11-02 09:00 那批推的是「0 单 / 0 个包裹,异常 0 个包裹」,业务以为真没发货,
|
||||||
|
// 其实是物流镜像表 11-01 07:55 后就没同步。群里绝不能出现「全部正常」这种假绿。
|
||||||
|
test('数据源停更导致 0 单 → 正文首部醒目告警,且不出现「全部正常」', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
rows: [],
|
||||||
|
summary: {
|
||||||
|
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||||||
|
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||||||
|
},
|
||||||
|
exclusions: {},
|
||||||
|
freshness: {
|
||||||
|
emptyWindow: true,
|
||||||
|
sourceLastUpdate: '2026-11-01 07:55:04',
|
||||||
|
sourceAgeMinutes: 1565,
|
||||||
|
sourceStale: true,
|
||||||
|
hint: '11-01 的发货记录在物流镜像表里一条都没有',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.match(built.text, /物流数据未同步/);
|
||||||
|
assert.match(built.text, /镜像表最后更新 2026-11-01 07:55:04/);
|
||||||
|
assert.match(built.text, /已 26\.1 小时未同步/);
|
||||||
|
assert.match(built.text, /不代表当天没发货/);
|
||||||
|
assert.ok(!built.text.includes('全部正常'), '数据没进库时不能说「全部正常」');
|
||||||
|
// 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收
|
||||||
|
assert.match(built.text, /【仓储物流转运异常】/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」,不说「全部正常」也不谎报停更', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
rows: [],
|
||||||
|
summary: {
|
||||||
|
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||||||
|
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||||||
|
},
|
||||||
|
exclusions: {},
|
||||||
|
freshness: { emptyWindow: true, sourceLastUpdate: '2026-11-02 09:58:00', sourceAgeMinutes: 2, sourceStale: false },
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.match(built.text, /在物流镜像表里 0 条发货记录/);
|
||||||
|
assert.match(built.text, /数据源本身正常/);
|
||||||
|
assert.match(built.text, /请到聚水潭核对/);
|
||||||
|
assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更');
|
||||||
|
assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」');
|
||||||
|
assert.ok(!built.text.includes('数据未覆盖核验时刻'), '0 单已有专门告警,别再叠一条快照告警');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ⑥ 有异常时的快照新鲜度也必须进正文(2026-11-11)──────────────────
|
||||||
|
// 实况:镜像表停在 11-10 11:53,09:10 那批算出 990 单全部异常,正文只有一句
|
||||||
|
// 「异常 990 个(100%)」。群里没法分辨「真异常」和「数据停在昨天」,
|
||||||
|
// 而这恰恰决定当天要不要催快递。下面把两种降级(未覆盖核验时刻 / 快照过旧)钉死。
|
||||||
|
test('快照没覆盖核验时刻 → 正文点名「未覆盖」,并把前一天的日期带上(不能只写 11:53)', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
freshness: {
|
||||||
|
snapshotAt: '2026-10-21 11:53:00',
|
||||||
|
ageMinutes: 1270,
|
||||||
|
stale: true,
|
||||||
|
coversDeadline: false,
|
||||||
|
emptyWindow: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
|
||||||
|
assert.match(built.text, /数据未覆盖核验时刻/);
|
||||||
|
assert.match(built.text, /最新只到 10-21 11:53/, '跨天必须带 MM-DD,否则会被读成当天 11:53');
|
||||||
|
assert.match(built.text, /本轮核验 09:00/, '同一天的时刻不带日期');
|
||||||
|
assert.match(built.text, /别按这个数去催快递/);
|
||||||
|
// 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收
|
||||||
|
assert.match(built.text, /【仓储物流转运异常】/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('快照同日但没过核验时刻 → 只写时分,不啰嗦日期', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
freshness: { snapshotAt: '2026-10-22 08:30:00', ageMinutes: 30, stale: false, coversDeadline: false },
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.match(built.text, /最新只到 08:30/);
|
||||||
|
assert.ok(!built.text.includes('10-22 08:30'), '同一天不重复写日期');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('快照过旧(stale)→ 正文写「偏旧」+ 未刷新时长,异常量标注仅供参考', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
freshness: { snapshotAt: '2026-10-22 09:05:00', ageMinutes: 200, stale: true, coversDeadline: true },
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.match(built.text, /物流快照偏旧/);
|
||||||
|
assert.match(built.text, /09:05/);
|
||||||
|
assert.match(built.text, /已 3\.3 小时未刷新/);
|
||||||
|
assert.match(built.text, /仅供参考/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('快照正常 → 正文不出现任何新鲜度告警(别把正常批次也刷成黄色)', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
freshness: { snapshotAt: '2026-10-22 09:05:00', ageMinutes: 3, stale: false, coversDeadline: true },
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.ok(!built.text.includes('数据未覆盖核验时刻'));
|
||||||
|
assert.ok(!built.text.includes('物流快照偏旧'));
|
||||||
|
assert.match(built.text, /异常 144 个包裹/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('快照字段缺失(老缓存 / 接口未返回)→ 不能崩,也不能瞎报警', () => {
|
||||||
|
const data = sampleData({ freshness: undefined });
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.match(built.text, /异常 144 个包裹/);
|
||||||
|
assert.ok(!built.text.includes('⚠️ 数据未覆盖'));
|
||||||
|
|
||||||
|
assert.equal(job.buildFreshnessWarning(undefined, {}), '');
|
||||||
|
assert.equal(job.buildFreshnessWarning({ coversDeadline: null }, {}), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ⑥ 推送失败要重试,不能「一天只有一次机会」(2026-11-11)──────────────
|
||||||
|
// 实况:11-11 09:10 那次撞上业务库连不上(状态里 `Failed to connect to 218.93.9.21:1433`),
|
||||||
|
// 老逻辑用 `HH:mm === sendTime` 严格相等,只给一分钟机会,失败后当天彻底不推、群里一整天没消息。
|
||||||
|
// 下面把窗口与节流的边界钉死,避免改成「每分钟都重跑」把 240s 的全量核验打成 DDoS。
|
||||||
|
function beijing(isoText) {
|
||||||
|
return new Date(isoText); // 测试用 UTC 写法,避免本机时区把断言带偏
|
||||||
|
}
|
||||||
|
|
||||||
|
test('重试调度:到点第一次跑,推成功之后当天不再跑', () => {
|
||||||
|
const sendTime = '09:10';
|
||||||
|
|
||||||
|
const first = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:10:00Z'), // 北京 09:10
|
||||||
|
sendTime,
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
assert.equal(first.run, true);
|
||||||
|
assert.equal(first.reason, 'first_round');
|
||||||
|
assert.equal(first.attempt, 1);
|
||||||
|
assert.equal(first.pushDate, '2026-11-11');
|
||||||
|
|
||||||
|
const sent = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:11:00Z'), // 北京 09:11,09:10 那轮已成功
|
||||||
|
sendTime,
|
||||||
|
state: { lastSentDate: '2026-11-11', lastSentAt: '2026-11-11T01:10:03Z' },
|
||||||
|
});
|
||||||
|
assert.equal(sent.run, false);
|
||||||
|
assert.equal(sent.reason, 'already_sent_today');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重试调度:没到点的分钟一律不跑(不会被「窗口」提前触发)', () => {
|
||||||
|
const decision = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:09:00Z'), // 北京 09:09
|
||||||
|
sendTime: '09:10',
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
assert.equal(decision.run, false);
|
||||||
|
assert.equal(decision.reason, 'before_time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重试调度:失败后按 5 分钟间隔重试,间隔内不重复拉全量核验', () => {
|
||||||
|
const sendTime = '09:10';
|
||||||
|
const failedState = { lastAttemptAt: '2026-11-11T01:10:00Z', attemptDate: '2026-11-11', attemptCount: 1 };
|
||||||
|
|
||||||
|
const tooSoon = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:13:00Z'), // 距上次尝试 3 分钟
|
||||||
|
sendTime,
|
||||||
|
state: failedState,
|
||||||
|
});
|
||||||
|
assert.equal(tooSoon.run, false);
|
||||||
|
assert.equal(tooSoon.reason, 'wait_interval');
|
||||||
|
|
||||||
|
const retry = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:15:00Z'), // 距上次尝试 5 分钟,正好到间隔
|
||||||
|
sendTime,
|
||||||
|
state: failedState,
|
||||||
|
});
|
||||||
|
assert.equal(retry.run, true);
|
||||||
|
assert.equal(retry.reason, 'retry');
|
||||||
|
assert.equal(retry.attempt, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重试调度:过了「推送时刻 + 30 分钟」窗口就不再补推', () => {
|
||||||
|
const sendTime = '09:10';
|
||||||
|
const failedState = { lastAttemptAt: '2026-11-11T01:35:00Z', attemptDate: '2026-11-11', attemptCount: 6 };
|
||||||
|
|
||||||
|
const inside = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:40:00Z'), // 北京 09:40,窗口最后一分钟
|
||||||
|
sendTime,
|
||||||
|
state: failedState,
|
||||||
|
});
|
||||||
|
assert.equal(inside.run, true, '窗口边界(含)仍要重试');
|
||||||
|
|
||||||
|
const closed = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:41:00Z'), // 北京 09:41
|
||||||
|
sendTime,
|
||||||
|
state: failedState,
|
||||||
|
});
|
||||||
|
assert.equal(closed.run, false);
|
||||||
|
assert.equal(closed.reason, 'window_closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重试调度:跨天后尝试次数归零,昨天的失败不节流今天的首轮', () => {
|
||||||
|
const decision = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-12T01:10:00Z'), // 北京 11-12 09:10
|
||||||
|
sendTime: '09:10',
|
||||||
|
state: { lastAttemptAt: '2026-11-11T01:35:00Z', attemptDate: '2026-11-11', attemptCount: 6 },
|
||||||
|
});
|
||||||
|
assert.equal(decision.run, true);
|
||||||
|
assert.equal(decision.reason, 'first_round');
|
||||||
|
assert.equal(decision.attempt, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重试调度:重试窗口不跨日(23:50 推送不会被次日 00:10 当成「窗口内」)', () => {
|
||||||
|
const decision = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T16:10:00Z'), // 北京 11-12 00:10
|
||||||
|
sendTime: '23:50',
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
assert.equal(decision.run, false);
|
||||||
|
assert.equal(decision.reason, 'before_time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重试调度:尝试记录残缺(有次数没时间)时不能卡死,该重试就重试', () => {
|
||||||
|
const decision = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:11:00Z'),
|
||||||
|
sendTime: '09:10',
|
||||||
|
state: { attemptDate: '2026-11-11', attemptCount: 2, lastAttemptAt: '' },
|
||||||
|
});
|
||||||
|
assert.equal(decision.run, true);
|
||||||
|
assert.equal(decision.reason, 'retry');
|
||||||
|
assert.equal(decision.attempt, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重试调度:sendTime 非法时安静不跑(配置坏了不能把定时任务打崩)', () => {
|
||||||
|
const decision = job.resolveScheduledPushDecision({
|
||||||
|
now: beijing('2026-11-11T01:10:00Z'),
|
||||||
|
sendTime: 'not-a-time',
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
assert.equal(decision.run, false);
|
||||||
|
assert.equal(decision.reason, 'bad_send_time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重试参数:默认 30 分钟窗口 / 5 分钟间隔,环境变量可覆盖', () => {
|
||||||
|
assert.equal(job.DEFAULT_RETRY_WINDOW_MINUTES, 30);
|
||||||
|
assert.equal(job.DEFAULT_RETRY_INTERVAL_MINUTES, 5);
|
||||||
|
assert.equal(job.minuteOfDay('09:10'), 550);
|
||||||
|
assert.equal(job.minuteOfDay('00:00'), 0);
|
||||||
|
assert.equal(job.minuteOfDay('23:59'), 1439);
|
||||||
|
assert.equal(job.minuteOfDay('9:10'), null, '必须是零填充的 HH:mm');
|
||||||
|
assert.equal(job.minuteOfDay(''), null);
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
294
backend/test/warehouseShipTimelinessNotify.test.js
Normal file
294
backend/test/warehouseShipTimelinessNotify.test.js
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
'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: '崔桥工厂仓',
|
||||||
|
logisticCompany: '武进韵达',
|
||||||
|
auditTime: '2026-10-27 06:32:11',
|
||||||
|
overdueMinutes: 388,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oId: '9855200',
|
||||||
|
soId: 'SO20261027001',
|
||||||
|
shopName: '天猫店',
|
||||||
|
warehouseName: '简尚-崔桥工厂仓',
|
||||||
|
centerName: '崔桥工厂仓',
|
||||||
|
logisticCompany: '常州安能新',
|
||||||
|
auditTime: '2026-10-26 09:05:00',
|
||||||
|
overdueMinutes: 2875,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oId: '9855300',
|
||||||
|
soId: '',
|
||||||
|
shopName: '抖音店',
|
||||||
|
warehouseName: '简尚-横林二仓',
|
||||||
|
centerName: '横林二仓',
|
||||||
|
// 快递名为空 → 明细里要显示「—」,不能出现 undefined
|
||||||
|
logisticCompany: '',
|
||||||
|
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('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看是哪家快递)', () => {
|
||||||
|
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||||
|
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
|
||||||
|
assert.ok(line, '明细第 2 行不见了');
|
||||||
|
const parts = line.replace(/^\d+\.\s*/, '').split('|');
|
||||||
|
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@ -4,7 +4,7 @@ import request from './request';
|
|||||||
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
||||||
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
// 仓储中心发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||||
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
||||||
// params.limit 明细条数上限
|
// params.limit 明细条数上限
|
||||||
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
||||||
@ -21,3 +21,40 @@ export const getWarehouseShipTimelinessRounds = () => request.get(
|
|||||||
'/api/warehouse-ship-timeliness/rounds',
|
'/api/warehouse-ship-timeliness/rounds',
|
||||||
{ timeout: 30000 }
|
{ timeout: 30000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 「超时未发」明细导出:不分页、不截断,返回本轮考核口径内的全部行。
|
||||||
|
// 与 /list 共用后端同一份缓存,所以页面刚刷过时这里是秒回;
|
||||||
|
// 冷启动(缓存已过期)仍要全量扫视图,超时同样放大到 3 分钟。
|
||||||
|
export const getWarehouseShipTimelinessExportRows = (params = {}) => request.get(
|
||||||
|
'/api/warehouse-ship-timeliness/export',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
timeout: TIMELINESS_TIMEOUT_MS,
|
||||||
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 两轮核验完成后推文本到钉钉群(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 }
|
||||||
|
);
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
|||||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||||
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
|
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
|
||||||
{
|
{
|
||||||
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||||
|
|||||||
@ -252,8 +252,15 @@ function WarehouseLogisticsException() {
|
|||||||
const [activeCenter, setActiveCenter] = useState('');
|
const [activeCenter, setActiveCenter] = useState('');
|
||||||
// 异常类型筛选:为空 = 全部
|
// 异常类型筛选:为空 = 全部
|
||||||
const [activeType, setActiveType] = useState('');
|
const [activeType, setActiveType] = useState('');
|
||||||
// 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
// 核验日;默认 null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
||||||
const [verifyDate, setVerifyDate] = useState(null);
|
// 🔥 钉钉消息里的链接带 `?date=YYYY-MM-DD`(见 buildPageLink),页面必须认这个参数 ——
|
||||||
|
// 否则群里点进来看到的是「今天这批」,跟消息里说的不是同一批,历史消息会误导人。
|
||||||
|
const [verifyDate, setVerifyDate] = useState(() => {
|
||||||
|
const raw = String(new URLSearchParams(window.location.search).get('date') || '').trim();
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/u.test(raw)) return null;
|
||||||
|
const parsed = dayjs(raw);
|
||||||
|
return parsed.isValid() ? parsed : null;
|
||||||
|
});
|
||||||
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
|
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
// 每天定点把异常文本推到钉钉群(「异常通知」)
|
// 每天定点把异常文本推到钉钉群(「异常通知」)
|
||||||
@ -564,14 +571,16 @@ function WarehouseLogisticsException() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{freshness && (freshness.coversDeadline === false || freshness.stale) ? (
|
{freshness && (freshness.emptyWindow || freshness.coversDeadline === false || freshness.stale) ? (
|
||||||
<Alert
|
<Alert
|
||||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
type={freshness.emptyWindow || freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||||
showIcon
|
showIcon
|
||||||
className="wle-alert"
|
className="wle-alert"
|
||||||
message={freshness.coversDeadline === false
|
message={freshness.emptyWindow
|
||||||
|
? '本批 0 单:物流镜像表里没有当天的发货记录,这个 0 不可当结论'
|
||||||
|
: (freshness.coversDeadline === false
|
||||||
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
||||||
: '物流数据可能滞后'}
|
: '物流数据可能滞后')}
|
||||||
description={freshness.hint}
|
description={freshness.hint}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -589,7 +598,11 @@ function WarehouseLogisticsException() {
|
|||||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary" className="wle-stat__sub">
|
<Text type="secondary" className="wle-stat__sub">
|
||||||
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹
|
{notStarted
|
||||||
|
? '—'
|
||||||
|
: (freshness?.emptyWindow
|
||||||
|
? '数据未同步,见上方红条'
|
||||||
|
: `共 ${number(summary.packageCount)} 个包裹`)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card bordered={false} className="wle-stat">
|
<Card bordered={false} className="wle-stat">
|
||||||
@ -779,14 +792,16 @@ function WarehouseLogisticsException() {
|
|||||||
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
|
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
|
||||||
: null}
|
: null}
|
||||||
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
||||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。
|
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流;
|
||||||
|
<b>现场拦截</b>(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常;
|
||||||
|
<b>转仓代发</b>(标签如「转x00005」,货由目标仓发出)—— 不算本仓异常,也不算本仓发货量。
|
||||||
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
||||||
|| exclusions.nonRealOrders > 0) ? (
|
|| exclusions.nonRealOrders > 0 || exclusions.transferOrders > 0) ? (
|
||||||
<>
|
<>
|
||||||
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
||||||
+ (exclusions.nonRealOrders || 0)}</b> 单
|
+ (exclusions.nonRealOrders || 0) + (exclusions.transferOrders || 0)}</b> 单
|
||||||
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
||||||
刷单/信誉单 {exclusions.nonRealOrders || 0} 单),
|
刷单/信誉单 {exclusions.nonRealOrders || 0} 单、转仓代发 {exclusions.transferOrders || 0} 单),
|
||||||
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
|
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
|
||||||
<>
|
<>
|
||||||
其中承运商剔除:<b>
|
其中承运商剔除:<b>
|
||||||
@ -800,6 +815,20 @@ function WarehouseLogisticsException() {
|
|||||||
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
|
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{exclusions?.interceptOrders > 0 ? (
|
||||||
|
<>
|
||||||
|
{' '}另有 <b>{exclusions.interceptOrders}</b> 单属<b>现场拦截(售后已签收)</b>
|
||||||
|
{exclusions.interceptReasons && Object.keys(exclusions.interceptReasons).length ? (
|
||||||
|
<>(原本会算「
|
||||||
|
{Object.entries(exclusions.interceptReasons)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, v]) => `${k} ${v} 单`)
|
||||||
|
.join(' / ')}
|
||||||
|
」)</>
|
||||||
|
) : null}
|
||||||
|
—— 货是被快递当场拦下原车拉回、由售后仓签收的,<b>仍计入发货分母、只是不再报异常</b>。
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<br />
|
<br />
|
||||||
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
|
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
|
||||||
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
|
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -111,6 +111,45 @@
|
|||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 超时卡下面的「其中 N 单数据未同步」—— 订单表停更时的警示小字(2026-10-28) */
|
||||||
|
.wst-stat__warn {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #cf1322;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 停更告警里逐个源列出「最后同步时间 / 滞后多久」 */
|
||||||
|
.wst-alert__sources {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-alert__source {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 明细里的「内部单号 + 未同步标记」 */
|
||||||
|
.wst-detail__oid {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-detail__stale {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding-inline: 5px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 主从两栏 ============ */
|
/* ============ 主从两栏 ============ */
|
||||||
.wst-body {
|
.wst-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -253,3 +292,70 @@
|
|||||||
gap: 2px 10px;
|
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