Compare commits

..

No commits in common. "main" and "codex/yanhui-wle-export-20261022" have entirely different histories.

28 changed files with 41 additions and 4017 deletions

View File

@ -1,73 +0,0 @@
'use strict';
const express = require('express');
const {
getReplenishExportRows,
getReplenishList,
} = require('../services/binReplenishService');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
const EXPORT_COLUMNS = [
{ key: 'skuId', title: '商品编码' },
{ key: 'productName', title: '品名' },
{ key: 'supplier', title: '供应商' },
{ key: 'statusLabel', title: '状态' },
{ key: 'pickQty', title: '拣货位库存' },
{ key: 'storageQty', title: '存储位库存' },
{ key: 'totalQty', title: '现有库存合计' },
{ key: 'orderQty', title: '待发订单' },
{ key: 'excess', title: '超出订单数' },
{ key: 'suggestQty', title: '建议补货量' },
{ key: 'shortfall', title: '存储缺口' },
];
/** GET /api/bin-replenish/list —— 缺货补货监控列表(订单驱动) */
router.get('/list', async (req, res, next) => {
try {
const payload = await getReplenishList({
warehouse: req.query.warehouse,
keyword: req.query.keyword,
status: req.query.status,
page: req.query.page,
pageSize: req.query.pageSize,
refresh: req.query.refresh,
});
res.json({ success: true, data: payload });
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/export —— 服务端全量导出 */
router.get('/export', async (req, res, next) => {
try {
const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const payload = await getReplenishExportRows({
warehouse: req.query.warehouse,
keyword: req.query.keyword,
status: req.query.status,
});
const fileName = `${payload.warehouse.shortName}待补明细`;
sendWorkbookDownload(res, {
fileName,
format,
sheets: [{
name: '缺货补货监控',
header: EXPORT_COLUMNS.map((column) => column.title),
rows: payload.rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
}],
});
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */
router.get('/ping', (req, res) => {
res.json({ success: true, data: { ok: true } });
});
module.exports = router;

View File

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

View File

@ -41,7 +41,6 @@ const dailyInventoryRoutes = require('./api/dailyInventory');
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
const binReplenishRoutes = require('./api/binReplenish');
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
const returnMonitorRoutes = require('./api/returnMonitor');
const returnStockWarningRoutes = require('./api/returnStockWarning');
@ -69,9 +68,6 @@ const {
const {
startMkhReplenishAlertScheduler,
} = require('./jobs/mkhReplenishAlertJob');
const {
startWarehouseLogisticsExceptionNotifyScheduler,
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
const { ensureLocalSchema } = require('./services/localSchema');
const {
@ -691,8 +687,6 @@ async function startServer(options = {}) {
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
// 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓)
cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes);
// 缺货补货监控(拣货位缺货 → 从存货位补货)
cubejsApp.use('/api/bin-replenish', authMiddleware, binReplenishRoutes);
// =====================
// 数据分析 API(公开访问,可根据需要添加认证)
@ -797,7 +791,6 @@ async function startServer(options = {}) {
startGoodsFirstSeenScheduler();
startAlibaba1688ImageSearchExpiryScheduler();
startMkhReplenishAlertScheduler();
startWarehouseLogisticsExceptionNotifyScheduler();
startDailyInventorySnapshotScheduler();
// Keep the first selection-pool page request from paying the remote schema

View File

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

View File

@ -1,53 +0,0 @@
'use strict';
/**
* 缺货补货监控(拣货位 → 存货位)本地表
* --------------------------------------------------------------------------
* 为什么需要本地表:
* 聚水潭「箱及仓位库存」在副本里是**视图** rpt.jst_packInventoryInfo,
* 只有货位号(bin)与数量,**没有货位类型**(拣货位 / 存货位)这层主数据
* (全库 5,496 个字段里没有 bin_type / location_type 一类的列,见 2026-10-22 复核)。
* 而业务判断「拣货位缺货、要从存货位补」必须先知道哪些 bin 是拣货位,
* 所以把业务给的货位清单导入本地库,作为判定基准。
*
* 两张表:
* bin_replenish_pick_bin —— 拣货位清单(按仓库隔离,可重复导入覆盖)
* bin_replenish_import_batch —— 导入批次日志(谁、什么时候、导入多少个)
*
* 未导入清单时,服务层回退到「pack_type = 'Bin' 的记录」作为拣货位口径,
* 页面会明确提示当前用的是回退口径。
*/
const database = require('../config/database');
const SCHEMA_SQL = [
`CREATE TABLE IF NOT EXISTS bin_replenish_pick_bin (
warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识(changzhou/henglin3/subei/suining/henglin2/wh7)',
bin_code VARCHAR(64) NOT NULL COMMENT '货位号(大写去空格)',
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
PRIMARY KEY (warehouse_key, bin_code),
KEY idx_brpb_batch (batch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位清单'`,
`CREATE TABLE IF NOT EXISTS bin_replenish_import_batch (
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识',
file_name VARCHAR(255) NULL COMMENT '文件名',
total_cells INT NOT NULL DEFAULT 0 COMMENT '扫描到的候选单元格数',
imported_rows INT NOT NULL DEFAULT 0 COMMENT '写入的货位数',
imported_by VARCHAR(100) NULL COMMENT '导入人',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
PRIMARY KEY (batch_id),
KEY idx_brpib_wh (warehouse_key, imported_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位导入批次'`,
];
async function ensureBinReplenishSchema() {
for (const sql of SCHEMA_SQL) {
// eslint-disable-next-line no-await-in-loop
await database.local.query(sql);
}
}
module.exports = { ensureBinReplenishSchema };

View File

@ -1,477 +0,0 @@
'use strict';
/**
* 缺货补货监控(订单驱动版,2026-10-22 重写)
* ==========================================================================
* 业务需求(用户确认):
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够待发订单拣,
* 需要从「存储库位」把货补过去。
*
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
* 拣货库位(拣货仓位):以 XA / XD / XE 开头
* 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头
* 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。
*
* ── 数据源 ───────────────────────────────────────────────────────────
* 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
* 订单:rpt.op_OrderNotDispatched(聚水潭「打单拣货/未发货」每日快照,SKU 级、组合装已展开)
* —— 直接对齐用户在聚水潭「打单拣货 → 简尚崔桥工厂仓」看到的单据。
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
* 按 wh_id 会把多仓混成一个桶。
*
* ── 待发口径(用户 2026-10-22 三次明确:「取打单拣货里的数据」)──────────
* 补货需求只来自「聚水潭打单拣货 / 发货中」的单据,**已发(Sent)不参与**。
* 数据源 rpt.op_OrderNotDispatched 是**每日快照**(一天一批,local_updated≈07:00):
* 取该仓 **最新快照日** 且 status='发货中'(历史标签曾为「发货中(打单拣货)」)的行,
* 按 sku_id 汇总 qty —— 即当前待拣/待发的订单量(组合装已展开为子件,带 src_combine_sku_id)。
* **不含**:已发(Sent)/取消/删除/拆单/合单、异常(Question/异常)、已付款待审核、待付款。
* (异常挂起单不保证近期发货,不计入补货需求;如需要可再把「异常」并入。)
*
* ── 判断逻辑(核心)──────────────────────────────────────────────────
* 对每一个 SKU(在拣货/存储位上有库存):
* 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
* 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
* 待发订单 orderQty = 该 SKU 的「打单拣货/发货中」快照待发量(组合装已展开)
* 超出订单数 excess = max(0, orderQty − pickQty)
* —— 拣货位不够待发订单拣,超出的这部分要从存储位补到拣货位
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
*
* 状态:
* short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
* need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
* enough 拣货位充足(0 < orderQty ≤ pickQty)
* no_order 无待发单(orderQty = 0)
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
*
* ⚠️ 性能:库存视图聚合 30s~1min;待发快照表(op_OrderNotDispatched)查询较快;
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
*/
const cloudDb = require('../config/cloudDatabase');
/**
* 仓库清单:**按 wms_co_id 取数**。
* 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定;
* 其他仓若用不同前缀,页面会标注,默认聚焦崔桥工厂仓。
*/
const WAREHOUSES = Object.freeze({
cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 },
shandong: { key: 'shandong', wmsCoId: 15137523, name: '简尚-山东青岗集仓', shortName: '山东青岗集仓', order: 20 },
henglin2: { key: 'henglin2', wmsCoId: 15033302, name: '简尚-横林二仓', shortName: '横林二仓', order: 30 },
subei: { key: 'subei', wmsCoId: 15038347, name: '简尚-新苏北仓', shortName: '新苏北仓', order: 40 },
henan: { key: 'henan', wmsCoId: 15539084, name: '简尚-河南洛宁仓', shortName: '河南洛宁仓', order: 50 },
shandongBrand: { key: 'shandongBrand', wmsCoId: 15182705, name: '简尚-山东品牌仓', shortName: '山东品牌仓', order: 60 },
cuiqiaoBcp: { key: 'cuiqiaoBcp', wmsCoId: 15718196, name: '简尚-崔桥工厂半成品包装项目仓', shortName: '崔桥半成品包装仓', order: 70 },
suining: { key: 'suining', wmsCoId: 15032603, name: '简尚-睢宁仓', shortName: '睢宁仓', order: 80 },
cuiqiaoBrand: { key: 'cuiqiaoBrand', wmsCoId: 15870495, name: '简尚-崔桥工厂品牌仓', shortName: '崔桥品牌仓', order: 90 },
henglin3: { key: 'henglin3', wmsCoId: 15043561, name: '简尚-横林三仓', shortName: '横林三仓', order: 100 },
});
const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order);
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
const MAX_PAGE_SIZE = 5000;
// ── 货位前缀规则(用户指定)────────────────────────────────────────
const PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']);
const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
// ── 待发口径:聚水潭「打单拣货/发货中」快照(rpt.op_OrderNotDispatched)────────
// 该表是每日快照;取该仓「最新快照日」且 status='发货中' 的行。
// 历史标签在 2026-03 之前叫「发货中(打单拣货)」,两个都认,避免换名漏数。
// 不含:已发/取消/删除/拆单/合单、异常、已付款待审核、待付款。
const PENDING_STATUS_LIST = Object.freeze(['发货中', '发货中(打单拣货)']);
const PENDING_STATUS_IN = PENDING_STATUS_LIST.map((s) => `'${s}'`).join(',');
const PENDING_ORDER_SCOPE = '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)';
const STATUS = Object.freeze({
SHORT: 'short',
NEED: 'need',
ENOUGH: 'enough',
NO_ORDER: 'no_order',
});
const STATUS_LABELS = Object.freeze({
short: '存储不足',
need: '需补货',
enough: '拣货位充足',
no_order: '无待发单',
});
// 排序权重:最紧急的排最前
const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 });
const cacheStore = new Map();
let cacheEpoch = 0;
function toText(value, maxLength = 200) {
return String(value ?? '').trim().slice(0, maxLength);
}
function toNumber(value, fallback = 0) {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
function round2(value) {
return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100;
}
function resolveWarehouseKey(value) {
const key = toText(value, 32).toLowerCase();
return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY;
}
function resolveWarehouse(value) {
return WAREHOUSES[resolveWarehouseKey(value)];
}
function resolveCacheTtlMs() {
const raw = process.env.BIN_REPLENISH_CACHE_TTL_MS;
if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CACHE_TTL_MS;
const num = Number(raw);
if (!Number.isFinite(num) || num < 0) return DEFAULT_CACHE_TTL_MS;
return num;
}
// ── 副本取数 ────────────────────────────────────────────────────────
/**
* 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
* 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。
*/
async function loadReplicaInventory(warehouse) {
const sql = `
SELECT p.sku_id AS skuId,
SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN})
THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty,
SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN})
THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty,
MAX(s.name) AS productName,
MAX(s.supplier_name) AS supplier
FROM rpt.jst_packInventoryInfo p
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
WHERE p.wms_co_id = @wmsCoId
AND p.wh_id = 1
AND ISNULL(p.bin, '') <> ''
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
GROUP BY p.sku_id`;
// 单仓按货位前缀聚合约 1.7s;走 querySlow(独立连接 + 池级超时)避免 60s 池超时误杀。
return cloudDb.querySlow(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 300000 });
}
/**
* 批量取 SKU 主数据(品名 / 供应商),用于补齐「有订单但无货位库存」的 SKU 名称。
* 这批 SKU 不在库存视图里,但确实有打单拣货待发单,必须进待补明细。
*/
async function loadSkuNames(skuIds) {
const uniq = [...new Set((skuIds || []).filter(Boolean).map((id) => toText(id, 120)))];
if (!uniq.length) return new Map();
const map = new Map();
const CHUNK = 400;
for (let i = 0; i < uniq.length; i += CHUNK) {
const chunk = uniq.slice(i, i + CHUNK);
const params = {};
const ph = chunk.map((_, idx) => `@s${idx}`).join(',');
chunk.forEach((id, idx) => { params[`s${idx}`] = id; });
const rows = await cloudDb.querySlow(
`SELECT sku_id AS skuId, name AS productName, supplier_name AS supplier
FROM rpt.jst_sku WHERE sku_id IN (${ph})`,
params,
{ requestTimeout: 120000 }
);
(rows || []).forEach((r) => {
const id = toText(r.skuId, 120);
if (id) map.set(id, { productName: toText(r.productName, 300), supplier: toText(r.supplier, 120) });
});
}
return map;
}
/**
* 各 SKU 的「待发」订单量:直接取聚水潭打单拣货快照表 rpt.op_OrderNotDispatched。
* 该表 SKU 级、组合装已展开为子件(src_combine_sku_id),qty 即该子件待发件数。
* 取「该仓最新快照日」+ status IN ('发货中','发货中(打单拣货)'),按 sku_id 汇总。
*
* ⚠️ 性能红线:副本库 mssql 池默认 60s requestTimeout,CTE+CROSS JOIN 写法实测 60s
* 超时失败。改成**两步 querySlow**(先取最新快照日,再按日范围汇总),实测 ~12s,
* 走独立连接 + 池级 requestTimeout(5 分钟),必须用它。
*/
async function loadPendingOrderMap(warehouse) {
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
const slowOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 300000 };
// step1:该仓最新快照日(local_updated≈每日 07:00 一批,MAX 落在最新那批)
const snapRows = await runQuery(
`SELECT CAST(MAX(local_updated) AS date) AS snapDate
FROM rpt.op_OrderNotDispatched
WHERE wms_co_id = @wmsCoId`,
{ wmsCoId: warehouse.wmsCoId },
slowOpts
);
const snapDate = snapRows && snapRows[0] && snapRows[0].snapDate;
if (!snapDate) return new Map();
// step2:最新快照日当天的「发货中」待发量,按 sku_id 汇总(组合装已展开)
const rows = await runQuery(
`SELECT o.sku_id AS skuId,
SUM(CAST(ISNULL(o.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.op_OrderNotDispatched o
WHERE o.wms_co_id = @wmsCoId
AND o.status IN (${PENDING_STATUS_IN})
AND o.local_updated >= @snapDate
AND o.local_updated < DATEADD(day, 1, @snapDate)
AND ISNULL(o.sku_id, '') <> ''
GROUP BY o.sku_id`,
{ wmsCoId: warehouse.wmsCoId, snapDate },
slowOpts
);
const map = new Map();
(rows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (skuId) map.set(skuId, toNumber(row.orderQty));
});
return map;
}
// ── 组装 ────────────────────────────────────────────────────────────
function buildSkuRows({ invRows, orderMap }) {
const rows = [];
(invRows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (!skuId) return;
const pickQty = round2(row.pickQty);
const storageQty = round2(row.storageQty);
const orderQty = round2(toNumber(orderMap.get(skuId), 0));
const excess = Math.max(0, round2(orderQty - pickQty));
let status = STATUS.NO_ORDER;
let suggestQty = 0;
let shortfall = 0;
let pickEmpty = false;
if (orderQty > 0) {
if (pickQty <= 0) pickEmpty = true;
if (excess > 0) {
const can = Math.min(excess, storageQty);
suggestQty = round2(can);
if (storageQty >= excess) {
status = STATUS.NEED;
} else {
status = STATUS.SHORT;
shortfall = round2(excess - storageQty);
}
} else {
status = STATUS.ENOUGH;
}
}
rows.push({
skuId,
productName: toText(row.productName, 300) || '',
supplier: toText(row.supplier, 120) || '',
status,
statusLabel: STATUS_LABELS[status],
pickQty,
storageQty,
totalQty: round2(pickQty + storageQty),
orderQty,
excess,
suggestQty,
shortfall,
pickEmpty,
});
});
return rows;
}
/**
* 汇总只统计「待补」SKU(need / short)——
* 即需要从存储仓位补货到拣货仓位的部分。对应看板四张指标卡:
* 实时代发订单数 = 待补 SKU 待发件数合计
* 拣货库存数 = 待补 SKU 拣货位库存合计
* 存储库存数 = 待补 SKU 存储位库存合计
* 缺货数 = 存储也补不齐的 SKU 数(status=short)
*/
function buildSummary(rows) {
const summary = {
skuTotal: 0,
need: 0,
short: 0,
orderQtyTotal: 0,
pickTotal: 0,
storageTotal: 0,
suggestQtyTotal: 0,
exceedTotal: 0,
shortfallTotal: 0,
};
rows.forEach((row) => {
summary.skuTotal += 1;
if (row.status === STATUS.NEED) summary.need += 1;
else if (row.status === STATUS.SHORT) summary.short += 1;
summary.orderQtyTotal += row.orderQty || 0;
summary.pickTotal += row.pickQty || 0;
summary.storageTotal += row.storageQty || 0;
summary.suggestQtyTotal += row.suggestQty || 0;
summary.exceedTotal += row.excess || 0;
summary.shortfallTotal += row.shortfall || 0;
});
summary.orderQtyTotal = round2(summary.orderQtyTotal);
summary.pickTotal = round2(summary.pickTotal);
summary.storageTotal = round2(summary.storageTotal);
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
summary.exceedTotal = round2(summary.exceedTotal);
summary.shortfallTotal = round2(summary.shortfallTotal);
return summary;
}
async function loadDataset(warehouse, { refresh = false } = {}) {
// 缓存键只用仓库(内部仍取「最新快照日」,不固定某天);跨天靠 TTL 失效即可。
const cacheKey = warehouse.key;
const cached = cacheStore.get(cacheKey);
const ttl = resolveCacheTtlMs();
const now = Date.now();
if (!refresh && cached && ttl > 0 && now - cached.at < ttl) {
return { ...cached, cacheHit: true };
}
const epoch = cacheEpoch;
const [invRows, orderMap] = await Promise.all([
loadReplicaInventory(warehouse),
loadPendingOrderMap(warehouse).catch(() => new Map()),
]);
// 把「有打单拣货待发单、但货位库存视图里没有记录」的 SKU 也补进明细,
// 否则这批最该补货(拣货位为 0)的行会被漏掉。
const invKeys = new Set((invRows || []).map((r) => toText(r.skuId, 120)));
const orderOnly = [...orderMap.keys()]
.filter((k) => !invKeys.has(k))
.map((k) => ({ skuId: k, pickQty: 0, storageQty: 0, productName: null, supplier: null }));
const combined = (invRows || []).concat(orderOnly);
if (orderOnly.length) {
const nameMap = await loadSkuNames(orderOnly.map((r) => r.skuId));
orderOnly.forEach((r) => {
const n = nameMap.get(r.skuId);
if (n) { r.productName = n.productName; r.supplier = n.supplier; }
});
}
const rows = buildSkuRows({ invRows: combined, orderMap });
const dataset = {
at: now,
rows,
warehouse,
orderScope: PENDING_ORDER_SCOPE,
meta: {
rowCount: rows.length,
orderSkuCount: orderMap.size,
},
};
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
if (epoch === cacheEpoch) cacheStore.set(cacheKey, dataset);
return { ...dataset, cacheHit: false };
}
function invalidateCache(warehouseKey) {
cacheEpoch += 1;
if (warehouseKey) cacheStore.delete(resolveWarehouseKey(warehouseKey));
else cacheStore.clear();
}
function sortRows(rows) {
return rows.slice().sort((a, b) => (
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|| (b.suggestQty || 0) - (a.suggestQty || 0)
|| (b.excess || 0) - (a.excess || 0)
|| String(a.skuId).localeCompare(String(b.skuId))
));
}
/**
* 只看「待补」数据:需要从存储仓位补货到拣货仓位的 SKU(need / short)。
* status='short' 时仅保留存储不足(缺货)的 SKU。
*/
function filterReplenish(rows, { keyword, status }) {
const kw = toText(keyword, 120).toLowerCase();
const onlyShort = toText(status, 12).toLowerCase() === 'short';
return rows.filter((row) => {
if (onlyShort) {
if (row.status !== STATUS.SHORT) return false;
} else if (row.status !== STATUS.NEED && row.status !== STATUS.SHORT) {
return false;
}
if (!kw) return true;
return row.skuId.toLowerCase().includes(kw)
|| String(row.productName || '').toLowerCase().includes(kw)
|| String(row.supplier || '').toLowerCase().includes(kw);
});
}
async function getReplenishList(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const replenished = filterReplenish(dataset.rows, options);
const sorted = sortRows(replenished);
const summary = buildSummary(replenished);
const page = Math.max(1, Number.parseInt(options.page, 10) || 1);
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50));
const start = (page - 1) * pageSize;
return {
rows: sorted.slice(start, start + pageSize),
total: sorted.length,
page,
pageSize,
summary,
warehouse,
warehouses: Object.values(WAREHOUSES),
orderScope: PENDING_ORDER_SCOPE,
cacheHit: dataset.cacheHit,
updatedAt: new Date(dataset.at).toISOString(),
cacheTtlMs: resolveCacheTtlMs(),
};
}
async function getReplenishExportRows(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const replenished = filterReplenish(dataset.rows, options);
return {
warehouse,
orderScope: PENDING_ORDER_SCOPE,
rows: sortRows(replenished),
summary: buildSummary(replenished),
};
}
/** 启动预热:库存视图 + 待发订单 union 各自 30s~1min,不能让首个用户请求等 */
async function warmBinReplenishCache() {
try {
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
const t0 = Date.now();
await loadDataset(warehouse, { refresh: true });
console.log(`[BinReplenish] 预热完成 ${warehouse.name},耗时 ${Date.now() - t0}ms`);
} catch (error) {
console.warn('[BinReplenish] 预热失败:', error.message);
}
}
module.exports = {
DEFAULT_WAREHOUSE_KEY,
PICK_PREFIXES,
STORE_PREFIXES,
STATUS,
STATUS_LABELS,
WAREHOUSES,
clearPickBins: async () => ({ removed: 0 }),
getReplenishExportRows,
getReplenishList,
loadDataset,
resolveWarehouse,
resolveWarehouseKey,
warmBinReplenishCache,
};

View File

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

View File

@ -16,8 +16,6 @@ const { ensureReturnMonitorSchema } = require('./returnMonitorSchema');
const {
ensureReturnStockWarningSchema,
} = require('./returnStockWarningSchema');
const { ensureBinReplenishSchema } = require('./binReplenishSchema');
const { warmBinReplenishCache } = require('./binReplenishService');
const { warmReturnStockWarningCache } = require('./returnStockWarningService');
const USER_COLUMNS = [
@ -961,11 +959,8 @@ async function ensureLocalSchema() {
await ensureProductMonitoringSchema();
await ensureReturnMonitorSchema();
await ensureReturnStockWarningSchema();
await ensureBinReplenishSchema();
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
void warmReturnStockWarningCache();
// 缺货补货监控(仓位库存视图单次查询 35~45s)后台预热,不阻塞启动
void warmBinReplenishCache();
await ensureSidebarRolePermissions();
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动

View File

@ -1,318 +0,0 @@
'use strict';
/**
* 「仓储中心物流转运异常」每天定点推文本到钉钉群 —— 配置与状态存取
* ────────────────────────────────────────────────────────────────────────────
* 配置分两层存(与统一通知中心同一套做法,见 yydNotificationCenterSettings):
* · 普通字段 → 本地库 `system_config`,key = warehouse_logistics_exception_notify_config
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
*
* 通道是「钉钉群自定义机器人 webhook」(2026-10-22 业务定:不发 .xlsx 附件,
* 群里直接看文本),所以只有两个凭据字段:
* · webhookUrl 群机器人地址(含 access_token;完整回显,业务要能核对)
* · webhookSecret 加签密钥(SEC 开头,只回掩码;安全设置选「关键词/IP」时留空)
*
* 推送时刻用页面上的 HH:mm(默认 09:10,核验 09:00 之后),**按北京时间解释**:
* 生产容器是 UTC,绝不能拿容器本地时间比。
*/
const database = require('../config/database');
const {
getResilientSecret,
saveResilientSecret,
} = require('./resilientSecretStore');
const CONFIG_KEY = 'warehouse_logistics_exception_notify_config';
const SECRET_KEY = 'warehouse_logistics_exception_notify_webhook_secret';
const STATE_KEY = 'warehouse_logistics_exception_notify_state';
const DEFAULT_SEND_TIME = '09:10';
// 明细条数:text 消息上限 20000 字节,30 条中文明细约 9~12KB,留足余量
const DEFAULT_MAX_DETAIL_ROWS = 30;
const MAX_DETAIL_ROWS_LIMIT = 50;
// 页面地址(消息里给「查看全部」链接)。生产是带 8443 的域名,别写成裸域名
const DEFAULT_PAGE_URL = process.env.WLE_PAGE_URL
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-logistics-exception';
const CACHE_TTL_MS = 30 * 1000;
let cachedConfig = null;
let cachedAt = 0;
function normalizeText(value, maxLength = 512) {
return String(value ?? '').trim().slice(0, maxLength);
}
function safeJsonParse(value, fallback = {}) {
if (value && typeof value === 'object') return value;
try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; }
}
function maskSecret(value) {
const text = normalizeText(value);
if (!text) return '';
if (text.length <= 8) return `${text.slice(0, 2)}******`;
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
}
/** HH:mm(00:00 ~ 23:59);非法输入退回默认 09:10,不抛错(避免把定时任务配挂) */
function normalizeSendTime(value) {
const text = normalizeText(value, 5);
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
return DEFAULT_SEND_TIME;
}
/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */
function normalizeMaxDetailRows(value) {
const num = Number(value);
if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS;
return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num)));
}
function normalizePageUrl(value) {
const text = normalizeText(value, 1024);
if (!text) return DEFAULT_PAGE_URL;
try {
const url = new URL(text);
if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL;
return url.toString();
} catch (_error) {
return DEFAULT_PAGE_URL;
}
}
function normalizeBoolean(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const text = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
if (['0', 'false', 'no', 'off'].includes(text)) return false;
return fallback;
}
function splitSendTime(sendTime) {
const [hour, minute] = normalizeSendTime(sendTime).split(':').map((part) => Number(part));
return { hour, minute };
}
async function upsertConfigRow(value) {
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[CONFIG_KEY]
);
const text = JSON.stringify(value);
if (rows[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[text, 'json', CONFIG_KEY]
);
} else {
await database.local.insert('system_config', {
config_key: CONFIG_KEY,
config_value: text,
config_type: 'json',
});
}
}
async function readConfigRow() {
try {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[CONFIG_KEY]
);
return safeJsonParse(rows[0]?.configValue, {});
} catch (error) {
console.warn('[WleNotify] 读取推送配置失败:', error.message);
return {};
}
}
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
async function getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh = false } = {}) {
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig };
const stored = await readConfigRow();
const secret = await getResilientSecret(SECRET_KEY, {
legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)],
fallbackValues: [normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
});
cachedConfig = {
enabled: normalizeBoolean(stored.enabled, false),
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|| normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK, 1024),
webhookSecret: secret.value,
webhookSecretSource: secret.source,
sendTime: normalizeSendTime(stored.sendTime || stored.send_time),
maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows),
atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false),
pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url),
source: Object.keys(stored).length > 0 ? 'database' : 'default',
};
cachedAt = Date.now();
return { ...cachedConfig };
}
/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/
function missingFields(config = {}) {
const missing = [];
if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址');
return missing;
}
function isWarehouseLogisticsExceptionNotifyReady(config = {}) {
return missingFields(config).length === 0;
}
function toAdminConfig(config = {}) {
return {
enabled: config.enabled === true,
webhookUrl: config.webhookUrl || '',
webhookSecretConfigured: Boolean(config.webhookSecret),
webhookSecretPreview: maskSecret(config.webhookSecret),
webhookSecretSource: config.webhookSecretSource || 'unconfigured',
sendTime: normalizeSendTime(config.sendTime),
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
atAll: config.atAll === true,
pageUrl: normalizePageUrl(config.pageUrl),
ready: isWarehouseLogisticsExceptionNotifyReady(config),
missingFields: missingFields(config),
source: config.source || 'default',
};
}
async function getWarehouseLogisticsExceptionNotifyAdminConfig() {
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
return toAdminConfig(config);
}
/**
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
*/
async function saveWarehouseLogisticsExceptionNotifyConfig(input = {}) {
const current = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512);
const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true;
const config = {
enabled: normalizeBoolean(input.enabled, current.enabled),
webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl,
webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret),
sendTime: input.sendTime === undefined && input.send_time === undefined
? normalizeSendTime(current.sendTime)
: normalizeSendTime(input.sendTime ?? input.send_time),
maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined
? normalizeMaxDetailRows(current.maxDetailRows)
: normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows),
atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true),
pageUrl: input.pageUrl === undefined && input.page_url === undefined
? normalizePageUrl(current.pageUrl)
: normalizePageUrl(input.pageUrl ?? input.page_url),
};
if (config.enabled) {
const missing = missingFields(config);
if (missing.length) {
throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 });
}
}
if (config.webhookUrl) {
// 这里只做「像不像」的浅校验;真正的连通性由「测试推送」按钮验证
const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender');
try {
config.webhookUrl = normalizeWebhookUrl(config.webhookUrl);
} catch (error) {
throw Object.assign(new Error(error.message), { status: 400 });
}
}
if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret);
await upsertConfigRow({
enabled: config.enabled,
webhookUrl: config.webhookUrl,
sendTime: config.sendTime,
maxDetailRows: config.maxDetailRows,
atAll: config.atAll,
pageUrl: config.pageUrl,
updatedAt: new Date().toISOString(),
});
cachedConfig = {
...config,
webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')),
source: 'database',
};
cachedAt = Date.now();
return toAdminConfig(cachedConfig);
}
/** 上次推送结果(页面展示 + 定时任务用来判断「今天是不是已经推过」) */
async function loadWarehouseLogisticsExceptionNotifyState() {
try {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[STATE_KEY]
);
const state = safeJsonParse(rows[0]?.configValue, {});
return state && typeof state === 'object' ? state : {};
} catch (error) {
console.warn('[WleNotify] 读取推送状态失败:', error.message);
return {};
}
}
async function saveWarehouseLogisticsExceptionNotifyState(next = {}) {
const value = JSON.stringify(next && typeof next === 'object' ? next : {});
try {
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[STATE_KEY]
);
if (rows[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[value, 'json', STATE_KEY]
);
} else {
await database.local.insert('system_config', {
config_key: STATE_KEY,
config_value: value,
config_type: 'json',
});
}
} catch (error) {
console.warn('[WleNotify] 保存推送状态失败:', error.message);
}
}
function clearNotifyConfigCache() {
cachedConfig = null;
cachedAt = 0;
}
module.exports = {
CONFIG_KEY,
DEFAULT_MAX_DETAIL_ROWS,
DEFAULT_PAGE_URL,
DEFAULT_SEND_TIME,
MAX_DETAIL_ROWS_LIMIT,
SECRET_KEY,
STATE_KEY,
clearNotifyConfigCache,
getWarehouseLogisticsExceptionNotifyAdminConfig,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
isWarehouseLogisticsExceptionNotifyReady,
loadWarehouseLogisticsExceptionNotifyState,
maskSecret,
missingFields,
normalizeMaxDetailRows,
normalizePageUrl,
normalizeSendTime,
saveWarehouseLogisticsExceptionNotifyConfig,
saveWarehouseLogisticsExceptionNotifyState,
splitSendTime,
toAdminConfig,
};

View File

@ -158,59 +158,6 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
{ 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
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
@ -369,19 +316,6 @@ function matchNonRealOrder(orderLabels) {
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, '');
}
/**
* 解析「核验批次」。
*
@ -436,7 +370,6 @@ module.exports = {
EXCLUDED_ORDER_STATUS_KEYWORDS,
EXCLUDED_ORDER_LABEL_KEYWORDS,
EXCLUDED_ORDER_TYPE_LABELS,
INTERCEPT_AFTERSALE_SOURCES,
EXCEPTION_TYPES,
TYPE_MAP,
TRANSIT_INFO_KEYWORDS,
@ -446,8 +379,6 @@ module.exports = {
matchExcludedCarrier,
matchVoidedOrder,
matchNonRealOrder,
matchTransferWarehouse,
TRANSFER_WAREHOUSE_LABEL_PATTERN,
resolveBatch,
beijingNow,
formatDate,

View File

@ -148,72 +148,6 @@ async function loadShippedPackages(batch) {
return rows || [];
}
// 售后拦截判定:一次 IN 多少单 + 查询超时。
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
// · 只查「本来就会报异常」的 oId(正常单不查,量级从 9800 降到几十);
// · 两套店铺表**并发**查,总耗时取较慢的那条(~8.5s),而不是相加的 13s;
// · 结果进 5 分钟结果缓存,页面自动刷新时基本命中缓存。
const INTERCEPT_QUERY_BATCH = 500;
const INTERCEPT_QUERY_TIMEOUT_MS = 240000;
/**
* 找出「现场拦截、货已被售后仓签收」的订单(业务 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.query(
`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`,
{},
{ timeoutMs: INTERCEPT_QUERY_TIMEOUT_MS }
);
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() {
return {
shippedOrders: 0,
@ -249,25 +183,6 @@ 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 = {}) {
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
@ -305,12 +220,6 @@ async function getLogisticsExceptions(options = {}) {
nonRealPackages: 0,
nonRealOrders: 0,
nonRealReasons: {},
interceptPackages: 0,
interceptOrders: 0,
interceptReasons: {},
transferPackages: 0,
transferOrders: 0,
transferReasons: {},
},
orders: [],
detailLimit: limit,
@ -322,24 +231,18 @@ async function getLogisticsExceptions(options = {}) {
};
}
// 键只到核验日 —— 页面(limit=1000)与导出(limit=5000)共用同一份全量结果
const cacheKey = batch.verifyDate;
const cacheKey = `${batch.verifyDate}|${limit}`;
const cachedEntry = resultCache.get(cacheKey);
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
return finalizeDetail(
cachedEntry.data,
limit,
true,
Math.round((Date.now() - cachedEntry.at) / 1000)
);
return {
...cachedEntry.data,
cached: true,
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
};
}
const rows = await loadShippedPackages(batch);
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
const interceptedOrders = await loadInterceptedOrders(rows);
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
const centerStat = new Map();
for (const center of registry.CENTERS) {
@ -363,8 +266,6 @@ async function getLogisticsExceptions(options = {}) {
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
const skippedByVoid = { 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) {
const queryTime = formatDateTime(row.query_time);
@ -408,18 +309,6 @@ async function getLogisticsExceptions(options = {}) {
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);
stat.shippedOrders.add(oId);
stat.packages += 1;
@ -429,23 +318,11 @@ async function getLogisticsExceptions(options = {}) {
const type = classify(row);
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 logisticAt = formatDateTime(row.logistic_time);
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
stat.exceptionOrders.add(oId);
stat.byType[type] = (stat.byType[type] || 0) + 1;
@ -543,19 +420,10 @@ async function getLogisticsExceptions(options = {}) {
nonRealPackages: skippedByType.packages,
nonRealOrders: skippedByType.orders.size,
nonRealReasons: skippedByType.reasons,
// 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母
interceptPackages: skippedByIntercept.packages,
interceptOrders: skippedByIntercept.orders.size,
interceptReasons: skippedByIntercept.reasons,
// 转仓代发(转x00005)—— 整单不计:既不算异常,也不进原仓发货分母
transferPackages: skippedByTransfer.packages,
transferOrders: skippedByTransfer.orders.size,
transferReasons: skippedByTransfer.reasons,
},
// 缓存里存全量,limit 由 finalizeDetail 在出口截断
orders: exceptions,
orders: exceptions.slice(0, limit),
detailLimit: limit,
truncated: false,
truncated: exceptions.length > limit,
freshness: {
snapshotAt,
ageMinutes,
@ -578,7 +446,7 @@ async function getLogisticsExceptions(options = {}) {
}
if (oldestKey) resultCache.delete(oldestKey);
}
return finalizeDetail(payload, limit, false, 0);
return payload;
}
/**

View File

@ -307,30 +307,8 @@ const CREATED_BACKFILL_BATCH = 200;
// 单子早随主单发走了,状态表还停在「发货中」。
// ⟹ 所以除了状态表的 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']);
// 订单表 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 = '已作废';
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
@ -576,9 +554,6 @@ module.exports = {
CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH,
VOID_ORDER_STATUS,
VOID_QUESTION_STATUS,
VOID_ORDER_LABEL_KEYWORDS,
AFTERSALE_VOID_STATUS,
SHIP_TIMELINESS_HOURS,
toNumber,
};

View File

@ -129,17 +129,9 @@ const {
CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH,
VOID_ORDER_STATUS,
VOID_QUESTION_STATUS,
VOID_ORDER_LABEL_KEYWORDS,
AFTERSALE_VOID_STATUS,
resolveCenter,
} = registry;
// `labels` 里命中任一关键词即视为作废(SQL 侧用 LIKE 拼,避免把长文本传回 Node)
const VOID_LABEL_SQL = VOID_ORDER_LABEL_KEYWORDS
.map((k) => `labels LIKE N'%${k}%'`)
.join(' OR ');
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
const DEFAULT_DETAIL_LIMIT = 500;
const MAX_DETAIL_LIMIT = 2000;
@ -232,7 +224,7 @@ async function loadPendingStates(round) {
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
// 就是库里存的北京时间,不会偏移 8 小时。
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
e.order_status_name, e.aftersale_status_name, e.logistic_status_name,
e.order_status_name, e.logistic_status_name,
e.logistic_id, e.logistic_company_name,
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
e.send_time, e.query_time
@ -301,8 +293,7 @@ async function backfillOrderFacts(oids) {
const results = await mapLimit(batches, 4, async (batch) => {
try {
return await cloudDatabase.querySlow(
`SELECT o_id, created, status, send_date, is_merge, is_split,
CASE WHEN ${VOID_LABEL_SQL} THEN 1 ELSE 0 END AS presaleVoid
`SELECT o_id, created, status, send_date, is_merge, is_split
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
{},
{ connectRequestTimeoutMs: 300000 }
@ -323,15 +314,10 @@ async function backfillOrderFacts(oids) {
createdAt: formatDateTime(r.created),
status,
sendDate,
// 作废(硬):被合并 / 被拆分 / 已取消 —— 这单不会自己发货,状态分类前就剔除
// 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货
void: VOID_ORDER_STATUS.includes(status)
|| Number(r.is_merge) === 1
|| Number(r.is_split) === 1,
// 作废(软):问题单 Question / 标签含「发货前售后」。
// ⚠️ 只能作用于「发货中」分支(见主循环),否则状态表「异常」的单
// 在订单表常也是 Question,会被一起吞掉 —— 实测 heldCount 472 → 10。
question: status === VOID_QUESTION_STATUS,
presaleVoid: Number(r.presaleVoid) === 1,
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
@ -583,19 +569,6 @@ async function getShipTimeliness(options = {}) {
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
// 🔥 2026-10-22:状态表说「发货中」,但订单表/售后维度已有作废证据 → 不考核。
// ⚠️ 必须放在「发货中」之后:订单表 Question 的单里有一大批在状态表是「异常」,
// 若在状态分类前判,会把 462 单「异常待处理」从 heldOrders 里吞掉。
// 三类证据(业务 2026-10-22 确认「聚水潭后台已作废却仍算超时」):
// · 状态表 aftersale_status_name =「已作废」(售后维度最权威)
// · 订单表 status = Question(聚水潭后台显示「已作废/问题单」)—— 9823980 / 9880810
// · 订单表 labels 含「发货前售后」(客户发货前退款/取消)—— 9823980 也带
if (String(row.aftersale_status_name || '').trim() === AFTERSALE_VOID_STATUS
|| (fact && (fact.question || fact.presaleVoid))) {
voidCount += 1;
continue;
}
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
if (!benchmarkAt) {
bucket.heldCount += 1;

View File

@ -144,16 +144,12 @@ test('状态为空时按运单号区分:有号=未揽收,无号=无物流单
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
const dbPath = require.resolve('../src/config/cloudDatabase');
function stubDb(rows, options = {}) {
function stubDb(rows) {
const calls = { times: 0, sql: [], params: [] };
const handler = async (sql, params) => {
calls.times += 1;
calls.sql.push(String(sql));
calls.params.push(params || {});
// 售后拦截判定会额外查两张售后表 —— 按表名分流,别把物流行当成售后行
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
return options.interceptRows || [];
}
return rows;
};
require.cache[dbPath] = {
@ -210,9 +206,9 @@ const rows = [
send_time: '2026-10-20 11:00:00' }),
];
async function withStub(fn, stubRows = rows, options = {}) {
async function withStub(fn, stubRows = rows) {
const realDb = require.cache[dbPath];
const calls = stubDb(stubRows, options);
const calls = stubDb(stubRows);
delete require.cache[svcPath];
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
try {
@ -280,20 +276,18 @@ test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async
test('结果缓存生效,force=1 绕过缓存重查', async () => {
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 first = await svc.getLogisticsExceptions(opts);
assert.equal(first.cached, false);
assert.equal(mainQueries(), 1);
assert.equal(calls.times, 1);
const second = await svc.getLogisticsExceptions(opts);
assert.equal(second.cached, true);
assert.equal(mainQueries(), 1, '第二次应命中缓存');
assert.equal(calls.times, 1, '第二次应命中缓存');
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
assert.equal(third.cached, false);
assert.equal(mainQueries(), 2, 'force=1 必须真查库');
assert.equal(calls.times, 2, 'force=1 必须真查库');
});
});
@ -326,39 +320,19 @@ test('导出:放开明细上限、返回全量异常(不受列表 2000 上
});
});
test('缓存只按核验日:页面加载后导出直接命中,不再重算整轮', async () => {
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => {
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) };
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(calls.times, 1);
const list = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(list.cached, false, '页面首次应查库');
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, '导出仍要拿到全部明细');
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
await svc.getLogisticsExceptionExportRows(opts);
assert.equal(calls.times, 2, '导出是首次查,应真查库');
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(listAgain.cached, true, '页面那份缓存应仍在');
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);
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
});
});
@ -542,45 +516,6 @@ test('刷单的包裹不进分母也不算异常', async () => {
}, 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 () => {
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
@ -588,87 +523,5 @@ test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
assert.ok(Array.isArray(data.exclusions.carriers));
assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
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')];
}
});

View File

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

View File

@ -207,13 +207,6 @@ function stubDb(dbPath, scenario = 'default') {
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
// ── 2026-10-22 作废单三类形态 ──
// 38:订单表 Question(聚水潭「已作废/问题单」)
38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }),
// 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转)
39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }),
// 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常
40: f('jst_Orders', { created: '2026-10-20 11:10:00' }),
};
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
@ -234,8 +227,6 @@ function stubDb(dbPath, scenario = 'default') {
shop_name: '测试店',
logistic_status_name: '',
query_time: '2026-10-20 11:18:27',
// 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到
aftersale_status_name: '暂无售后',
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
...o,
@ -335,19 +326,6 @@ function stubDb(dbPath, scenario = 'default') {
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
last_oa_confirm_time: '2026-09-22 09:05:00' }),
// ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)──
// 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810
mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 09:10:00',
last_oa_confirm_time: '2026-10-20 09:15:00' }),
// 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980
mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 10:10:00',
last_oa_confirm_time: '2026-10-20 10:15:00' }),
// 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除
mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', aftersale_status_name: '已作废',
pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15:00' }),
];
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
@ -414,7 +392,6 @@ function stubDb(dbPath, scenario = 'default') {
send_date: x.send_date,
is_merge: x.is_merge,
is_split: x.is_split,
presaleVoid: x.presaleVoid || 0,
};
});
}
@ -529,13 +506,12 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
// 被排除的仓不能算
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
// 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废)
for (const id of ['9', '10', '11', '12', '38', '39', '40']) {
for (const id of ['9', '10', '11', '12']) {
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
}
// 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类)
assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数');
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
@ -585,16 +561,13 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
// 补创建时间:必须走两张订单表;6 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来)
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
for (const bs of calls.backfillSql) {
assert.match(bs,
/SELECT o_id, created, status, send_date, is_merge, is_split,\s+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
`补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`);
assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u,
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
'多取一列就会把 0.2s 拖到 38s');
// labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文
assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔');
}
// ⚠️ 窗口参数走 params,不在 SQL 里手写

View File

@ -7,9 +7,8 @@
"test": "node --test test/*.test.mjs",
"prebuild": "node ./scripts/write-build-version.mjs",
"dev": "node ./scripts/write-build-version.mjs && vite --configLoader native",
"build": "node ./scripts/verify-router-lazy.mjs && vite build && node ./scripts/verify-planning-entry.mjs",
"build": "vite build && node ./scripts/verify-planning-entry.mjs",
"check": "npm run lint && npm test && npm run build",
"verify:router-lazy": "node ./scripts/verify-router-lazy.mjs",
"verify:public-build": "node ./scripts/verify-public-build.mjs",
"lint": "eslint .",
"preview": "vite preview"

View File

@ -1,70 +0,0 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
/**
* 校验路由表里每个 renderLazyComponent(X) 用到的组件 X 都在同文件里
* 用 `const X = lazy(() => import(...))` 声明过。
*
* 为什么要这个检查(2026-10-22 生产事故):
* PR #74 新增「缺货补货监控」时,router/index.jsx 里写了
* `element: renderLazyComponent(BinReplenishMonitor)` 却漏了 lazy 声明。
* 这行在模块顶层执行 → 加载即抛 `ReferenceError: BinReplenishMonitor is not defined`
* → 整个 React 应用挂掉、**全站白屏,连登录页都渲染不出来**。
* 而 `vite build` 完全能构建成功(未声明标识符在 JS 里是合法语法),
* 所以必须在构建期用静态扫描拦住。
*/
export async function verifyRouterLazy(routerPath = path.resolve(process.cwd(), 'src/router/index.jsx')) {
const source = await readFile(routerPath, 'utf8');
const used = new Set();
for (const match of source.matchAll(/renderLazyComponent\s*\(\s*([A-Za-z_$][\w$]*)/g)) {
used.add(match[1]);
}
const declared = new Set();
for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*lazy\s*\(/g)) {
declared.add(match[1]);
}
const missing = [...used].filter((name) => !declared.has(name)).sort();
if (missing.length > 0) {
const lines = missing.map((name) => {
const idx = source.indexOf(`renderLazyComponent(${name}`);
const line = idx === -1 ? '?' : source.slice(0, idx).split('\n').length;
return ` - ${name} (router/index.jsx:${line} 使用,缺少 const ${name} = lazy(() => import('...')) 声明)`;
});
throw new Error(
'router/index.jsx 有组件被 renderLazyComponent 使用但未声明为 lazy,' +
'这会导致运行时 ReferenceError 并让整站白屏:\n' +
lines.join('\n'),
);
}
return { routerPath, used: [...used].sort(), declared: [...declared].sort() };
}
async function main() {
const routerArg = process.argv[2];
const routerPath = routerArg
? path.resolve(process.cwd(), routerArg)
: path.resolve(process.cwd(), 'src/router/index.jsx');
const result = await verifyRouterLazy(routerPath);
process.stdout.write(
`Verified router lazy declarations: ${result.used.length} used, ${result.declared.length} declared ` +
`(${path.relative(process.cwd(), result.routerPath)})\n`,
);
}
const isDirectExecution = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
: false;
if (isDirectExecution) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
});
}

View File

@ -1,20 +0,0 @@
import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 缺货补货监控(订单驱动版)
// 判断口径:待发订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
export const getBinReplenishList = (params = {}) => request.get(
'/api/bin-replenish/list',
{ params, timeout: LONG_TIMEOUT_MS }
);
export const exportBinReplenish = (params = {}) => request.get(
'/api/bin-replenish/export',
{
params,
responseType: 'blob',
timeout: LONG_TIMEOUT_MS,
silentError: true,
}
);

View File

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

View File

@ -29,7 +29,6 @@ export const DEFAULT_SIDEBAR_CONFIG = {
],
},
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
{ key: 'bin-replenish-monitor', type: 'item', label: '缺货补货监控', icon: 'inbox', routeKey: 'bin-replenish-monitor', visible: true, order: 90 },
{
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
children: [

View File

@ -12,7 +12,6 @@ export const SIDEBAR_PAGES = [
{ pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_henan', path: '/planning/operation/returns-inventory/henan', name: '河南客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'bin_replenish_monitor', path: '/planning/operation/bin-replenish-monitor', name: '缺货补货监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
];
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {

View File

@ -134,11 +134,6 @@ const SIDEBAR_ROUTE_MAP = {
'daily-inventory': { path: '/planning/operation/daily-inventory' },
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' },
'bin-replenish-monitor': {
path: '/planning/operation/bin-replenish-monitor',
activePath: '/planning/operation/bin-replenish-monitor',
matchMode: 'prefix',
},
settings: { path: '/settings' },
'online-desktop-users': { path: '/settings/online-desktop-users' },
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },

View File

@ -1,411 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Empty, Input, Segmented, Spin, Table, Tag, Tooltip,
message,
} from 'antd';
import {
DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined,
} from '@ant-design/icons';
import { exportBinReplenish, getBinReplenishList } from '../../api/binReplenish';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import { SycmPage } from '../../components/Common';
import './style.css';
const num = (value) => (value === null || value === undefined || value === ''
? '--'
: Number(value).toLocaleString('zh-CN'));
const STATUS_TAGS = {
short: { color: 'red', label: '存储不足' },
need: { color: 'gold', label: '需补货' },
enough: { color: 'green', label: '拣货位充足' },
no_order: { color: 'default', label: '无待发单' },
};
const STATUS_OPTIONS = [
{ label: '待补货', value: 'replenish' },
{ label: '仅缺货', value: 'short' },
];
const BinReplenishMonitor = () => {
const [warehouse, setWarehouse] = useState('cuiqiao');
const [status, setStatus] = useState('replenish');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState({
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderScope: '', updatedAt: '', cacheHit: false,
});
const keywordTimer = useRef(null);
const load = useCallback(async (options = {}) => {
const isRefresh = options.refresh === true;
if (isRefresh) setRefreshing(true);
else setLoading(true);
setError('');
try {
const response = await getBinReplenishList({
warehouse,
status,
keyword,
page: options.page || page,
pageSize: options.pageSize || pageSize,
refresh: isRefresh ? 'true' : undefined,
});
const payload = response?.data?.data || response?.data || {};
setData({
rows: payload.rows || [],
total: payload.total || 0,
summary: payload.summary || {},
warehouse: payload.warehouse || null,
warehouses: payload.warehouses || [],
orderScope: payload.orderScope || '',
updatedAt: payload.updatedAt || '',
cacheHit: Boolean(payload.cacheHit),
});
if (isRefresh) message.success('已重新回源取数');
} catch (err) {
setError(err?.message || '加载失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [warehouse, status, keyword, page, pageSize]);
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [warehouse, status, page, pageSize]);
useEffect(() => {
if (keywordTimer.current) clearTimeout(keywordTimer.current);
keywordTimer.current = setTimeout(() => {
setPage(1);
load({ page: 1 });
}, 400);
return () => { if (keywordTimer.current) clearTimeout(keywordTimer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
const summary = data.summary;
const needCount = (summary.need || 0) + (summary.short || 0);
const metrics = useMemo(() => ([
{
key: 'order',
tone: 'blue',
label: '实时代发订单数',
value: summary.orderQtyTotal || 0,
unit: '件',
foot: `待补 ${num(summary.skuTotal)} 个 SKU`,
tip: '待补 SKU 的待发订单件数合计(取聚水潭「打单拣货 → 发货中」每日快照,组合装已展开为子件;不含已发/异常/待审核)',
},
{
key: 'pick',
tone: 'green',
label: '拣货库存数',
value: summary.pickTotal || 0,
unit: '件',
foot: '待补 SKU 拣货位库存',
tip: '待补 SKU 在 XA / XD / XE 拣货位上的库存合计(库存取自聚水潭「箱及仓位库存 · 简尚-崔桥工厂仓 主仓」)',
},
{
key: 'store',
tone: 'orange',
label: '存储库存数',
value: summary.storageTotal || 0,
unit: '件',
foot: '待补 SKU 存储位库存',
tip: '待补 SKU 在 XX / X9 / X5 / X4 存储位上的库存合计(同上来源,主仓)',
},
{
key: 'short',
tone: 'red',
label: '缺货数',
value: summary.skuTotal || 0,
unit: '个',
foot: `其中存储不足 ${num(summary.short)} 个,缺口 ${num(summary.shortfallTotal)} 件`,
tip: '需要从存储位补到拣货位的 SKU 数(待发订单量 > 拣货位库存);其中存储位也补不齐的标为「缺货」,需采购 / 紧急调拨',
},
]), [summary, needCount]);
const handleExport = async () => {
try {
const response = await exportBinReplenish({ warehouse, status, keyword });
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_待发订单.xlsx`);
} catch (error_) {
const text = await readBlobErrorMessage(error_);
message.error(text || '导出失败');
}
};
const columns = [
{
title: '状态',
dataIndex: 'status',
width: 100,
fixed: 'left',
render: (value) => {
const tag = STATUS_TAGS[value] || STATUS_TAGS.no_order;
return <Tag color={tag.color} className="brp-status-tag">{tag.label}</Tag>;
},
},
{
title: '商品编码',
dataIndex: 'skuId',
width: 175,
render: (value, row) => (
<div className="brp-sku">
<span className="brp-sku__code">{value}</span>
{row.supplier ? <span className="brp-sku__supplier">{row.supplier}</span> : null}
</div>
),
},
{ title: '品名', dataIndex: 'productName', width: 260, ellipsis: true },
{
title: '拣货位库存',
dataIndex: 'pickQty',
width: 100,
align: 'right',
render: (value, row) => (
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>
{num(value)}{row.pickEmpty ? ' ⚠' : ''}
</span>
),
},
{
title: '存储位库存',
dataIndex: 'storageQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
title: '待发订单',
dataIndex: 'orderQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
title: '超出订单数',
dataIndex: 'excess',
width: 100,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--warn' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
),
},
{
title: '建议补货量',
dataIndex: 'suggestQty',
width: 110,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--suggest' : 'brp-qty brp-qty--zero'}>
{num(value)}
</span>
),
},
{
title: '存储缺口',
dataIndex: 'shortfall',
width: 100,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--danger' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
),
},
{
title: '现有库存',
dataIndex: 'totalQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
];
return (
<SycmPage className="brp-page">
<div className="brp-hero">
<div className="brp-hero__main">
<h1 className="brp-hero__title">缺货补货监控</h1>
<p className="brp-hero__desc">
本看板只盯<b>崔桥工厂仓(主仓)</b>的<b>当日待补</b>数据:也就是那些待发订单超过拣货位库存、
需要把货从<b>存储位</b>补到<b>拣货位</b>的 SKU。订单取自聚水潭「打单拣货 → 简尚-崔桥工厂仓」的发货中单据,
库存取自聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」。<b>已发货的不再计入</b>(货已发出、不再消耗拣货位)。
</p>
</div>
<div className="brp-hero__meta">
<div>
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
</div>
<div>
统计口径:<b>{data.orderScope || '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)'}</b>
</div>
<div>
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
</div>
<div>
共 <b>{num(summary.skuTotal)}</b> 个待补 SKU,其中存储不足(缺货)<b>{num(summary.short)}</b> 个
</div>
</div>
</div>
<div className="brp-metrics">
{metrics.map((item) => (
<div key={item.key} className={`brp-metric brp-metric--${item.tone}`}>
<Tooltip title={item.tip}>
<div className="brp-metric__label">
{item.label}
<InfoCircleOutlined className="brp-metric__info" />
</div>
</Tooltip>
<div className="brp-metric__value">
{num(item.value)}
<span className="brp-metric__unit">{item.unit}</span>
</div>
<div className="brp-metric__foot">{item.foot}</div>
</div>
))}
</div>
<div className="brp-toolbar">
<span className="brp-toolbar__field">
<span className="brp-toolbar__label">仓库</span>
<span className="brp-toolbar__static">简尚-崔桥工厂仓(主仓)</span>
</span>
<span className="brp-toolbar__field">
<span className="brp-toolbar__label">范围</span>
<Segmented
value={status}
onChange={(value) => { setStatus(value); setPage(1); }}
options={STATUS_OPTIONS.map((item) => ({
...item,
label: item.value === 'replenish'
? `${item.label} ${num(needCount)}`
: `${item.label} ${num(summary.short || 0)}`,
}))}
/>
</span>
<Input
allowClear
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜商品编码 / 品名 / 供应商"
prefix={<SearchOutlined />}
style={{ width: 260 }}
/>
<span className="brp-toolbar__spacer" />
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={refreshing}
onClick={() => load({ refresh: true })}
>
刷新数据
</Button>
</div>
<Alert
type="info"
showIcon
className="brp-alert"
message="货位口径(崔桥工厂仓 主仓约定)"
description={(
<span>
库存只取 <b>崔桥工厂仓主仓(wh_id=1)</b>;拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;
其余货位(DB / Y* / Z* 等,占比极小)不计入。
判断规则:<b>待发订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
</span>
)}
/>
{error ? (
<Alert
type="error"
showIcon
className="brp-alert"
message={error}
action={<Button size="small" onClick={() => load()}>重试</Button>}
/>
) : null}
<Spin
spinning={loading}
tip="正在聚合仓位库存 + 待发订单,首次约 30~90 秒"
wrapperClassName="brp-table-spin"
>
<Table
rowKey="skuId"
size="small"
className="brp-table"
columns={columns}
dataSource={data.rows}
scroll={{ x: 1500 }}
locale={{
emptyText: (
<Empty
description={status === 'replenish'
? '当前没有需要补货的拣货位'
: '没有符合条件的商品'}
/>
),
}}
pagination={{
current: page,
pageSize,
total: data.total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (total) => `共 ${total} 个 SKU`,
onChange: (nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
},
}}
/>
</Spin>
<div className="brp-notes">
<div className="brp-notes__title">数据来源与口径说明</div>
<ul>
<li>
<b>库存</b>:聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」(rpt.jst_packInventoryInfo,仅 <b>wh_id=1 主仓</b>),
按商品编码(sku_id)聚合。「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;
「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
</li>
<li>
<b>订单</b>:直接取聚水潭「<b>打单拣货 → 简尚-崔桥工厂仓</b>」的发货中单据快照 = rpt.op_OrderNotDispatched
(该仓<b>最新快照日</b>、status=发货中),SKU 级、组合装已展开为子件。
<b>已发货 / 异常挂起 / 已付款待审核 / 待付款均不计入</b>——货已发出或短期不会拣,不该驱动补货。
口径仅覆盖<b>聚水潭线上店铺渠道</b>(运营中心 / 分销等),<b>不含全民(qmcust) B2B 客户单</b>。
</li>
<li>
<b>看板范围</b>:本看板<b>只显示当日待补</b> SKU —— 即「待发订单量 &gt; 拣货位库存」、需要从存储位补到拣货位的这部分
(含拣货位为 0、库存视图里查不到但有打单拣货待发单的 SKU)。其余「拣货位充足 / 无待发单」的 SKU 不进本看板。
</li>
<li>
<b>判断规则</b>:对每个待补 SKU,<b>超出订单数</b> = max(0, 待发订单量 − 拣货位库存);
「建议补货量」= min(超出订单数, 存储位库存);存储位也不够时标记「缺货」,缺口需采购 / 紧急调拨。
</li>
<li>
<b>统计范围</b>:取该仓<b>最新一天的「打单拣货」快照</b>(ETL 约每日 07:00 刷新);已发/异常/待审核一律排除。
</li>
<li>
<b>时效</b>:库存视图聚合约 1~2s,待发快照两步查询约 12s;服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等约 15~30 秒。
</li>
</ul>
</div>
</SycmPage>
);
};
export default BinReplenishMonitor;

View File

@ -1,313 +0,0 @@
/* 缺货补货监控 —— 页头 / 指标卡 / 工具栏 / 明细表 */
.brp-page {
--brp-gap: 12px;
--brp-border: var(--sycm-border-card, #e6e6e6);
--brp-radius: var(--sycm-radius-xl, 12px);
--brp-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
--brp-blue: #2062e6;
--brp-red: #e5484d;
--brp-orange: #e8890c;
--brp-grey: #6b7280;
--brp-green: #1bb574;
}
/* ── 页头 ───────────────────────────────────────────────── */
.brp-hero {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 24px;
margin-bottom: var(--brp-gap);
}
.brp-hero__main {
min-width: 320px;
}
.brp-hero__title {
margin: 0;
color: var(--sycm-text-strong, #262626);
font-size: 20px;
font-weight: 600;
line-height: 1.3;
}
.brp-hero__desc {
margin: 4px 0 0;
max-width: 820px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.6;
}
.brp-hero__meta {
display: grid;
gap: 3px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.6;
text-align: right;
}
.brp-warn {
color: var(--brp-orange);
}
.brp-ok {
color: var(--brp-green);
}
/* ── 指标卡 ─────────────────────────────────────────────── */
.brp-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--brp-gap);
margin-bottom: var(--brp-gap);
}
.brp-metric {
position: relative;
overflow: hidden;
padding: 12px 14px 11px;
border: 1px solid var(--brp-border);
border-radius: var(--brp-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--brp-shadow);
}
.brp-metric::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: var(--brp-tone, var(--brp-blue));
}
.brp-metric--red { --brp-tone: var(--brp-red); }
.brp-metric--orange { --brp-tone: var(--brp-orange); }
.brp-metric--blue { --brp-tone: var(--brp-blue); }
.brp-metric--grey { --brp-tone: var(--brp-grey); }
.brp-metric__label {
display: flex;
align-items: center;
gap: 4px;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
cursor: help;
}
.brp-metric__info {
font-size: 11px;
}
.brp-metric__value {
margin-top: 6px;
color: var(--sycm-text-strong, #262626);
font-size: 24px;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1.15;
}
.brp-metric__unit {
margin-left: 4px;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
}
.brp-metric__foot {
margin-top: 6px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.4;
}
/* ── 工具栏 ─────────────────────────────────────────────── */
.brp-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--brp-border);
border-radius: var(--brp-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--brp-shadow);
}
.brp-toolbar__field {
display: inline-flex;
align-items: center;
gap: 6px;
}
.brp-toolbar__label {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
white-space: nowrap;
}
.brp-toolbar__spacer {
flex: 1 1 auto;
}
.brp-alert {
margin-bottom: 10px;
}
/* ── 表格 ───────────────────────────────────────────────── */
.brp-table-spin {
display: block;
}
.brp-table .ant-table-thead > tr > th {
background: #fafafa;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.brp-table .ant-table-tbody > tr > td {
font-size: 12px;
}
.brp-status-tag {
margin-inline-end: 0;
}
.brp-sku {
display: flex;
flex-direction: column;
gap: 2px;
}
.brp-sku__code {
color: var(--sycm-text-strong, #262626);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.brp-sku__supplier {
overflow: hidden;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.brp-bins {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.brp-bin {
margin: 0;
padding: 0 6px;
border-color: #d9e2f2;
background: #f5f8ff;
color: #2b4a80;
font-size: 11px;
line-height: 18px;
}
.brp-bin b {
margin-left: 4px;
font-variant-numeric: tabular-nums;
}
.brp-bin.is-zero {
border-color: #f3d2d2;
background: #fff6f6;
color: #a33a3a;
}
.brp-bin--more {
border-color: #eee;
background: #fafafa;
color: #8c8c8c;
}
.brp-qty {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.brp-qty--zero {
color: var(--brp-red);
font-weight: 600;
}
.brp-qty--suggest {
color: var(--brp-blue);
font-weight: 600;
}
.brp-qty--warn {
color: var(--brp-orange);
font-weight: 600;
}
.brp-qty--danger {
color: var(--brp-red);
font-weight: 700;
}
.brp-qty--muted {
color: var(--sycm-text-tertiary, #8c8c8c);
font-weight: 400;
}
.brp-muted {
color: var(--sycm-text-tertiary, #8c8c8c);
}
/* ── 口径说明 ───────────────────────────────────────────── */
.brp-notes {
margin-top: 14px;
padding: 12px 14px;
border: 1px dashed var(--brp-border);
border-radius: var(--brp-radius);
background: var(--sycm-bg-container, #fff);
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.7;
}
.brp-notes__title {
margin-bottom: 6px;
color: var(--sycm-text-strong, #262626);
font-weight: 600;
}
.brp-notes ul {
margin: 0;
padding-left: 18px;
}
.brp-notes li + li {
margin-top: 4px;
}
@media (max-width: 1200px) {
.brp-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.brp-hero__meta {
text-align: left;
}
}
@media (max-width: 640px) {
.brp-metrics {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@ -1,17 +1,13 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Collapse, DatePicker, Dropdown, Empty, Input, InputNumber, Modal, Space, Spin, Statistic,
Switch, Table, Tag, TimePicker, Tooltip, Typography, message,
Alert, Button, Card, DatePicker, Dropdown, Empty, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
} from 'antd';
import { BellOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmPage } from '../../components/Common';
import {
getWarehouseLogisticsExceptions,
getWarehouseLogisticsExceptionExportRows,
getWarehouseLogisticsExceptionNotifySettings,
saveWarehouseLogisticsExceptionNotifySettings,
runWarehouseLogisticsExceptionNotify,
} from '../../api/warehouseLogisticsException';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
@ -82,70 +78,6 @@ const EXPORT_MENU_ITEMS = [
{ key: 'csv', label: '导出 CSV(.csv)' },
];
// 「异常通知」表单初始值。加签密钥永远不回显明文:留空 = 沿用服务端已存的那份
const EMPTY_NOTIFY_FORM = {
enabled: false,
sendTime: '09:10',
webhookUrl: '',
webhookSecret: '',
maxDetailRows: 30,
atAll: false,
pageUrl: '',
};
// 后端「没推」的原因 → 人话(后端 reason 是给日志用的英文标识)
const NOTIFY_REASON_TEXT = {
disabled: '推送开关没打开',
not_configured: 'Webhook 地址还没填',
already_sent_today: '今天已经推过了(一天只推一次)',
not_started: '这一批还没到核验时刻,没有结论可推',
dry_run: '仅预览,未发送',
};
// 钉钉群机器人 webhook 怎么拿 —— 业务自己配,写清楚省得来问
const NOTIFY_HELP_ITEMS = [
{
key: 'webhook',
label: '① 拿群机器人 Webhook 地址',
children: (
<div className="wle-notify__help">
电脑版钉钉 → 打开目标群 → 右上角 <b>群设置</b> → <b>机器人</b> → <b>添加机器人</b> →
选 <b>自定义</b> → 起个名字(如「物流异常通知」)→ 复制 <b>Webhook 地址</b>。
<br />
地址形如 <code>https://oapi.dingtalk.com/robot/send?access_token=xxxx</code>,
整串(含 access_token)直接贴到下面。
</div>
),
},
{
key: 'security',
label: '② 安全设置:加签密钥(可选)',
children: (
<div className="wle-notify__help">
添加机器人时如果安全设置选了 <b>加签</b>,把以 <code>SEC</code> 开头的密钥填到「加签密钥」里。
<br />
如果选的是 <b>自定义关键词</b>,密钥留空即可 —— 但关键词必须包含 <b>异常</b>
(本模块消息抬头固定是「【仓储物流转运异常】」,天然命中)。
<br />
如果选的是 <b>IP 地址(段)</b>,需要把生产服务器 IP 加进白名单,密钥同样留空。
</div>
),
},
{
key: 'why',
label: '③ 为什么群里只有文字,没有 Excel 附件',
children: (
<div className="wle-notify__help">
钉钉的<b>自定义机器人 webhook</b> 只支持 text / markdown / link / actionCard,
<b>不支持发文件</b>。所以这里推的是<b>文本消息</b>:汇总数字 + 前 N 条明细 + 页面链接。
<br />
要全量明细或 Excel,点消息末尾的链接进页面,用右上角「导出」按钮导出即可
(口径与消息完全一致)。
</div>
),
},
];
const DETAIL_COLUMNS = [
{
title: '发货仓',
@ -256,15 +188,6 @@ function WarehouseLogisticsException() {
const [verifyDate, setVerifyDate] = useState(null);
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
const [exporting, setExporting] = useState(false);
// 每天定点把异常文本推到钉钉群(「异常通知」)
const [notifyOpen, setNotifyOpen] = useState(false);
const [notifyLoading, setNotifyLoading] = useState(false);
const [notifySaving, setNotifySaving] = useState(false);
const [notifyRunning, setNotifyRunning] = useState(false);
const [notifyConfig, setNotifyConfig] = useState(null);
const [notifyForm, setNotifyForm] = useState(EMPTY_NOTIFY_FORM);
// 预览正文:放在弹窗内受控渲染(React 19 下 Modal.info 静态方法弹不出来)
const [notifyPreview, setNotifyPreview] = useState(null);
const load = useCallback(async (force = false) => {
setLoading(true);
@ -359,129 +282,6 @@ function WarehouseLogisticsException() {
}
};
// ── 异常通知:每天定点把异常情况以文本推到钉钉群 ──────────────────────────
// 后端只回加签密钥掩码,所以表单里的 webhookSecret 永远是空的 —— 留空 = 沿用已存的
const applyNotifyConfig = useCallback((config) => {
setNotifyConfig(config || null);
setNotifyForm({
enabled: config?.enabled === true,
sendTime: config?.sendTime || '09:10',
webhookUrl: config?.webhookUrl || '',
webhookSecret: '',
maxDetailRows: Number.isFinite(Number(config?.maxDetailRows))
? Number(config.maxDetailRows)
: 30,
atAll: config?.atAll === true,
pageUrl: config?.pageUrl || '',
});
}, []);
const openNotifySettings = useCallback(async () => {
setNotifyOpen(true);
setNotifyPreview(null);
setNotifyLoading(true);
try {
const result = await getWarehouseLogisticsExceptionNotifySettings();
if (!result || result.success === false) {
message.error(result?.error || '读取异常通知配置失败');
return;
}
applyNotifyConfig(result.data);
} catch (loadError) {
message.error(loadError?.response?.data?.error || loadError?.message || '读取异常通知配置失败');
} finally {
setNotifyLoading(false);
}
}, [applyNotifyConfig]);
const buildNotifyPayload = useCallback(() => ({
enabled: notifyForm.enabled,
sendTime: notifyForm.sendTime,
webhookUrl: String(notifyForm.webhookUrl || '').trim(),
webhookSecret: String(notifyForm.webhookSecret || '').trim(),
maxDetailRows: Number(notifyForm.maxDetailRows) || 0,
atAll: notifyForm.atAll === true,
pageUrl: String(notifyForm.pageUrl || '').trim(),
}), [notifyForm]);
const handleSaveNotify = async ({ silent = false } = {}) => {
setNotifySaving(true);
try {
const result = await saveWarehouseLogisticsExceptionNotifySettings(buildNotifyPayload());
if (!result || result.success === false) {
message.error(result?.error || '保存异常通知配置失败');
return null;
}
applyNotifyConfig(result.data);
if (!silent) message.success('异常通知配置已保存');
return result.data;
} catch (saveError) {
message.error(saveError?.response?.data?.error || saveError?.message || '保存异常通知配置失败');
return null;
} finally {
setNotifySaving(false);
}
};
/**
* mode: 'dry' 预览正文(不发送)| 'probe' 只发一句通道测试 | 'send' 立即推送本批
* 都要先保存:否则「改完 webhook 直接点测试」会拿旧地址发,排错时会很迷惑。
*
* ⚠️ 预览**不能**用 `Modal.info` 静态方法:本项目跑 React 19,antd v5 的静态方法
* 拿不到 ConfigProvider context(控制台只给一行 warning),弹窗**静默不渲染**。
* 所以预览改成弹窗内的受控区块(notifyPreview)。
*/
const handleRunNotify = async (mode) => {
const saved = await handleSaveNotify({ silent: true });
if (!saved) return;
setNotifyRunning(true);
try {
const payload = mode === 'dry'
? { dryRun: true, force: true }
: { probe: mode === 'probe', force: true };
const result = await runWarehouseLogisticsExceptionNotify(payload);
if (!result || result.success === false) {
message.error(result?.error || '推送到钉钉群失败');
return;
}
const info = result.data || {};
if (mode === 'dry') {
setNotifyPreview({
text: info.text || '',
rowCount: info.rowCount ?? 0,
listedCount: info.listedCount ?? 0,
bytes: info.bytes || 0,
truncated: info.truncated === true,
batchLabel: info.batch?.label || '',
});
message.success('已生成预览(未发送),正文见下方');
} else if (mode === 'probe') {
message.success('通道测试已发出,去钉钉群看有没有收到那条测试消息');
} else if (info.sent) {
setNotifyPreview(null);
message.success(`已推送到钉钉群(${info.rowCount ?? 0} 条异常,列出 ${info.listedCount ?? 0} 条)`);
} else {
message.warning(`未推送:${NOTIFY_REASON_TEXT[info.reason] || info.reason || '未知原因'}`);
}
const latest = await getWarehouseLogisticsExceptionNotifySettings();
if (latest?.data) applyNotifyConfig(latest.data);
} catch (runError) {
message.error(runError?.response?.data?.error || runError?.message || '推送到钉钉群失败');
} finally {
setNotifyRunning(false);
}
};
const notifyTimeValue = useMemo(() => {
const [hours, minutes] = String(notifyForm.sendTime || '09:10')
.split(':')
.map((part) => Number(part) || 0);
return dayjs().hour(hours).minute(minutes).second(0);
}, [notifyForm.sendTime]);
const notifyLastState = notifyConfig?.lastState || {};
return (
<SycmPage className="wle-page">
<header className="wle-header">
@ -549,7 +349,6 @@ function WarehouseLogisticsException() {
>
<Button icon={<DownloadOutlined />} loading={exporting}>导出</Button>
</Dropdown>
<Button icon={<BellOutlined />} onClick={openNotifySettings}>异常通知</Button>
</Space>
</div>
</Card>
@ -779,16 +578,14 @@ function WarehouseLogisticsException() {
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
: null}
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流;
<b>现场拦截</b>(售后单有签收单号且实退数量 &gt; 0,货已被售后仓签收)—— 物流环节已结束,不算异常;
<b>转仓代发</b>(标签如「转x00005」,货由目标仓发出)—— 不算本仓异常,也不算本仓发货量。
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|| exclusions.nonRealOrders > 0 || exclusions.transferOrders > 0) ? (
|| exclusions.nonRealOrders > 0) ? (
<>
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
+ (exclusions.nonRealOrders || 0) + (exclusions.transferOrders || 0)}</b> 单
+ (exclusions.nonRealOrders || 0)}</b> 单
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
刷单/信誉单 {exclusions.nonRealOrders || 0} 单、转仓代发 {exclusions.transferOrders || 0} 单),
刷单/信誉单 {exclusions.nonRealOrders || 0} 单),
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
<>
其中承运商剔除:<b>
@ -802,179 +599,10 @@ function WarehouseLogisticsException() {
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
</>
) : 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 />
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
</Text>
<Modal
title="异常通知 · 每天把异常明细推到钉钉群"
open={notifyOpen}
onCancel={() => setNotifyOpen(false)}
width={720}
destroyOnHidden
maskClosable={false}
styles={{ body: { maxHeight: '62vh', overflowY: 'auto' } }}
footer={[
<Button key="close" onClick={() => setNotifyOpen(false)}>关闭</Button>,
<Button key="probe" onClick={() => handleRunNotify('probe')} loading={notifyRunning} disabled={notifySaving}>
测试通道
</Button>,
<Button key="preview" onClick={() => handleRunNotify('dry')} loading={notifyRunning} disabled={notifySaving}>
预览正文
</Button>,
<Button key="save" onClick={() => handleSaveNotify()} loading={notifySaving}>保存</Button>,
<Button key="run" type="primary" onClick={() => handleRunNotify('send')} loading={notifyRunning} disabled={notifySaving}>
立即推送到群
</Button>,
]}
>
<Spin spinning={notifyLoading}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type={notifyConfig?.ready ? 'success' : 'warning'}
showIcon
message={notifyConfig?.ready
? `通道已配好,每天 ${notifyForm.sendTime} 自动推送(0 条异常也推,用来确认任务在跑)`
: 'Webhook 地址还没配,填好保存后点「测试通道」验一下'}
description={notifyConfig?.ready ? null : (
<span>
缺:<b>{(notifyConfig?.missingFields || []).join(' / ') || '(尚未读取)'}</b>
</span>
)}
/>
<div className="wle-notify__row">
<span className="wle-notify__label">每天推送</span>
<Switch
checked={notifyForm.enabled}
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
checkedChildren="开"
unCheckedChildren="关"
/>
<TimePicker
format="HH:mm"
value={notifyTimeValue}
allowClear={false}
minuteStep={5}
style={{ width: 96 }}
onChange={(value) => {
if (!value) return;
setNotifyForm((old) => ({ ...old, sendTime: value.format('HH:mm') }));
}}
/>
<Text type="secondary">核验在 09:00,推送默认 09:10(早于核验点会推空,系统自己会跳过)</Text>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">Webhook</span>
<Input
value={notifyForm.webhookUrl}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxxx"
onChange={(e) => setNotifyForm((old) => ({ ...old, webhookUrl: e.target.value }))}
style={{ flex: 1, minWidth: 360 }}
/>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">加签密钥</span>
<Input.Password
value={notifyForm.webhookSecret}
placeholder={notifyConfig?.webhookSecretConfigured
? `已保存 ${notifyConfig.webhookSecretPreview || ''},留空表示不修改`
: 'SEC 开头;安全设置选「关键词 / IP」时留空'}
onChange={(e) => setNotifyForm((old) => ({ ...old, webhookSecret: e.target.value }))}
style={{ flex: 1, minWidth: 300 }}
/>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">明细条数</span>
<InputNumber
min={0}
max={notifyConfig?.maxDetailRowsLimit || 50}
precision={0}
value={notifyForm.maxDetailRows}
onChange={(value) => setNotifyForm((old) => ({ ...old, maxDetailRows: value ?? 0 }))}
style={{ width: 96 }}
/>
<Text type="secondary">
消息里最多列几条明细(0 = 只发汇总);钉钉文本上限 20000 字节,超了会自动精简
</Text>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">@所有人</span>
<Switch
checked={notifyForm.atAll}
onChange={(checked) => setNotifyForm((old) => ({ ...old, atAll: checked }))}
checkedChildren="开"
unCheckedChildren="关"
/>
<Text type="secondary">打开后每批异常都会 @所有人(有异常才推,0 条也会推一条平安消息)</Text>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">页面链接</span>
<Input
value={notifyForm.pageUrl}
placeholder="消息末尾「查看全部」用的地址(留空用默认生产地址)"
onChange={(e) => setNotifyForm((old) => ({ ...old, pageUrl: e.target.value }))}
style={{ flex: 1, minWidth: 360 }}
/>
</div>
{notifyPreview ? (
<div className="wle-notify__preview">
<div className="wle-notify__preview-head">
<Text type="secondary">
{notifyPreview.batchLabel || '预览'}
{' '}| 异常 <b>{notifyPreview.rowCount}</b> 条
{' '}| 消息里列出 <b>{notifyPreview.listedCount}</b> 条
{' '}| {Math.round((notifyPreview.bytes / 1024) * 10) / 10} KB
{notifyPreview.truncated ? '(超长已精简)' : ''}
</Text>
<Button type="link" size="small" onClick={() => setNotifyPreview(null)}>收起</Button>
</div>
<pre>{notifyPreview.text || '(消息正文为空)'}</pre>
</div>
) : null}
<div className="wle-notify__last">
<Text type="secondary">
上次推送:
{notifyLastState.lastSentDate
? <> {notifyLastState.lastSentDate} {String(notifyLastState.lastSentAt || '').slice(11, 19)}
{' '}· 异常 {notifyLastState.rowCount ?? 0} 条
{' '}· 列出 {notifyLastState.listedCount ?? '—'} 条
{notifyLastState.truncated ? '(超长已精简)' : ''}</>
: ' 还没有成功推送记录'}
</Text>
{notifyLastState.lastError ? (
<div className="wle-notify__error">
<Text type="danger">最近一次失败:{String(notifyLastState.lastError).slice(0, 300)}</Text>
</div>
) : null}
</div>
<Collapse size="small" items={NOTIFY_HELP_ITEMS} />
</Space>
</Spin>
</Modal>
</SycmPage>
);
}

View File

@ -276,70 +276,3 @@
gap: 2px 10px;
}
}
/* ── 异常通知(每天推 Excel 到钉钉群)───────────────────────────── */
.wle-notify__row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wle-notify__label {
flex: 0 0 88px;
font-size: 13px;
color: var(--sycm-text-secondary);
}
.wle-notify__last {
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
}
.wle-notify__error {
margin-top: 4px;
}
.wle-notify__help {
font-size: 12px;
line-height: 1.8;
color: var(--sycm-text-secondary);
}
.wle-notify__help code {
padding: 0 4px;
border-radius: 3px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04));
}
.wle-notify__preview {
padding: 10px 12px;
border: 1px solid var(--sycm-border, rgba(0, 0, 0, 0.08));
border-radius: 8px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
line-height: 1.8;
}
.wle-notify__preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wle-notify__preview pre {
margin: 8px 0 0;
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-bg-container, #fff);
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
line-height: 1.7;
/* 30 条明细大概 40 行,别把整个弹窗撑爆;内部滚动看全 */
max-height: 280px;
overflow: auto;
}

View File

@ -25,10 +25,6 @@ const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
// ⚠️ 每个在下面路由表里被 renderLazyComponent(...) 用到的页面都必须先在这里声明。
// 漏一个 = 模块顶层抛 ReferenceError(xxx is not defined)→ 整个应用白屏、连登录页都进不去。
// PR #74 就漏了 BinReplenishMonitor,导致生产 10-22 全站白屏(2026-10-22 修复)。
const BinReplenishMonitor = lazy(() => import('../pages/BinReplenishMonitor'));
const lazyLoadingElement = <PageLoadingFallback />;
@ -141,11 +137,6 @@ const router = createBrowserRouter([
path: 'planning/operation/picking-bin-sales-analysis',
element: renderLazyComponent(PickingBinSalesAnalysis),
},
// 缺货补货监控:拣货位缺货 → 从存货位补货
{
path: 'planning/operation/bin-replenish-monitor',
element: renderLazyComponent(BinReplenishMonitor),
},
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
{