Compare commits
No commits in common. "main" and "codex/warehouse-ship-timeliness-rules-1021" have entirely different histories.
main
...
codex/ware
@ -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;
|
||||
@ -1,173 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常 —— HTTP 路由(2026-10-21 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* GET /api/warehouse-logistics-exception/list
|
||||
* query:
|
||||
* 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
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getLogisticsExceptions,
|
||||
getLogisticsExceptionExportRows,
|
||||
} = require('../services/warehouseLogisticsExceptionService');
|
||||
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();
|
||||
|
||||
function sendError(res, error, fallback) {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[WarehouseLogisticsException]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
// 转运异常总览:12 个发货仓异常单量 + 明细
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const data = await getLogisticsExceptions(req.query || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载物流转运异常失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 导出用:与 /list 同一套口径,只是把明细上限放开(不分页)
|
||||
// 页面筛选(仓 / 异常类型)由前端用同一份过滤函数处理,保证导出的就是页面看到的
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const data = await getLogisticsExceptionExportRows(req.query || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出物流转运异常失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 口径元数据(不查库,秒回):页面文案与图例用它,避免前后端各写一套
|
||||
router.get('/meta', (_req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
verifyHour: VERIFY_HOUR,
|
||||
lookbackDays: LOOKBACK_DAYS,
|
||||
centers: CENTERS.map((c) => ({ key: c.key, name: c.name })),
|
||||
exceptionTypes: EXCEPTION_TYPES,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── 每天推送到钉钉群 ────────────────────────────────────────────────────────
|
||||
|
||||
// 读配置: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;
|
||||
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* GET /api/warehouse-ship-timeliness/list
|
||||
* query:
|
||||
@ -11,33 +11,12 @@
|
||||
* GET /api/warehouse-ship-timeliness/rounds
|
||||
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
||||
*
|
||||
* GET /api/warehouse-ship-timeliness/export (2026-10-27 新增)
|
||||
* 「超时未发」明细导出:不分页、不截断,返回本轮全部行(与 /list 同一份缓存)
|
||||
* query: round / force
|
||||
*
|
||||
* ── 两轮核验完成后推钉钉群(2026-10-27 新增)────────────────────────────────
|
||||
* GET /api/warehouse-ship-timeliness/notify/settings 读配置(加签密钥只回掩码)
|
||||
* PUT /api/warehouse-ship-timeliness/notify/settings 保存配置(加签密钥留空 = 不变)
|
||||
* GET /api/warehouse-ship-timeliness/notify/state 上次推送结果
|
||||
* POST /api/warehouse-ship-timeliness/notify/run 立即推送一次(dryRun / probe / force)
|
||||
*
|
||||
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getShipTimeliness,
|
||||
listShipTimelinessExportRows,
|
||||
} = require('../services/warehouseShipTimelinessService');
|
||||
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
|
||||
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const {
|
||||
getShipTimelinessNotifyAdminConfig,
|
||||
getShipTimelinessNotifyRuntimeConfig,
|
||||
loadShipTimelinessNotifyState,
|
||||
missingFields,
|
||||
saveShipTimelinessNotifyConfig,
|
||||
} = require('../services/warehouseShipTimelinessNotifySettings');
|
||||
const { pushShipTimelinessNotify } = require('../jobs/warehouseShipTimelinessNotifyJob');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -80,95 +59,4 @@ router.get('/rounds', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 导出「超时未发」明细:**不分页、不截断**,返回本轮考核口径内的全部行。
|
||||
*
|
||||
* query: round(同 /list)、force=1(绕过缓存重查)
|
||||
*
|
||||
* 与 /list 共用 getShipTimeliness(同一份缓存),所以页面刚刷过时这里是秒回。
|
||||
* 前端拿到 rows 后按选中的仓在前端过滤 —— 口径与页面完全一致,不需要第二套 filter。
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const data = await listShipTimelinessExportRows(req.query || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出超时未发明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 两轮核验完成后推钉钉群 ──────────────────────────────────────────────────
|
||||
|
||||
// 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端
|
||||
router.get('/notify/settings', async (_req, res) => {
|
||||
try {
|
||||
const [config, state] = await Promise.all([
|
||||
getShipTimelinessNotifyAdminConfig(),
|
||||
loadShipTimelinessNotifyState(),
|
||||
]);
|
||||
res.json({ success: true, data: { ...config, lastState: state || {} } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '读取发货时效通知配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存配置;加签密钥留空表示「沿用已存的」,不必每次重填
|
||||
router.put('/notify/settings', async (req, res) => {
|
||||
try {
|
||||
const data = await saveShipTimelinessNotifyConfig(req.body || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) || 400;
|
||||
console.error('[WarehouseShipTimeliness] 保存通知配置失败:', error?.message);
|
||||
res.status(status).json({ success: false, error: error.message || '保存发货时效通知配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/notify/state', async (_req, res) => {
|
||||
try {
|
||||
const state = await loadShipTimelinessNotifyState();
|
||||
res.json({ success: true, data: state || {} });
|
||||
} catch (error) {
|
||||
sendError(res, error, '读取推送状态失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 立即推送一次(页面「立即推送」/「预览正文」/「测试通道」)。
|
||||
* body:
|
||||
* round 'NOON' | 'AFTERNOON',不传按当前时刻自动判定
|
||||
* dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径)
|
||||
* probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通)
|
||||
* force true = 忽略「本轮已推过」(默认手动触发就是 true,除非显式给 false)
|
||||
*/
|
||||
router.post('/notify/run', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const dryRun = body.dryRun === true || body.dryRun === 'true';
|
||||
const probe = body.probe === true || body.probe === 'true';
|
||||
const force = body.force === undefined ? true : (body.force === true || body.force === 'true');
|
||||
if (!dryRun) {
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
const missing = missingFields(config);
|
||||
if (missing.length) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `请先补全并保存:${missing.join(' / ')}`,
|
||||
data: { missingFields: missing },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await pushShipTimelinessNotify({
|
||||
dryRun,
|
||||
probe,
|
||||
force,
|
||||
round: body.round || undefined,
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
sendError(res, error, '推送发货时效通知失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -36,12 +36,10 @@ const productMonitoringRoutes = require('./api/productMonitoring');
|
||||
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
|
||||
const mkhBomRoutes = require('./api/mkhBom');
|
||||
const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness');
|
||||
const warehouseLogisticsExceptionRoutes = require('./api/warehouseLogisticsException');
|
||||
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,12 +67,6 @@ const {
|
||||
const {
|
||||
startMkhReplenishAlertScheduler,
|
||||
} = require('./jobs/mkhReplenishAlertJob');
|
||||
const {
|
||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
|
||||
const {
|
||||
startShipTimelinessNotifyScheduler,
|
||||
} = require('./jobs/warehouseShipTimelinessNotifyJob');
|
||||
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||
const { ensureLocalSchema } = require('./services/localSchema');
|
||||
const {
|
||||
@ -681,10 +673,8 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||
// 仓储中心发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
|
||||
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
|
||||
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
|
||||
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
|
||||
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
|
||||
cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes);
|
||||
@ -694,8 +684,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(公开访问,可根据需要添加认证)
|
||||
@ -800,8 +788,6 @@ async function startServer(options = {}) {
|
||||
startGoodsFirstSeenScheduler();
|
||||
startAlibaba1688ImageSearchExpiryScheduler();
|
||||
startMkhReplenishAlertScheduler();
|
||||
startWarehouseLogisticsExceptionNotifyScheduler();
|
||||
startShipTimelinessNotifyScheduler();
|
||||
startDailyInventorySnapshotScheduler();
|
||||
|
||||
// Keep the first selection-pool page request from paying the remote schema
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -1,693 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-27 原话):
|
||||
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
|
||||
* 18:00 核验的订单,核验完成后也通知。」
|
||||
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
|
||||
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
|
||||
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
|
||||
* ⚠️ 2026-10-28 起数据源换成订单两表(jst_Orders / jst_qmcust_orderlist),
|
||||
* 起算点是**审核时间** `last_oa_confirm_time`(不是订单创建时间);
|
||||
* 取不到审核时间的单回退创建时间,正文里在该时间后加 `*` 提示。
|
||||
* 推送正文写「审核 HH:mm」(此前一版误写「创建」,已纠正)。
|
||||
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
|
||||
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
|
||||
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
|
||||
*
|
||||
* 消息内容:
|
||||
* 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等)
|
||||
* + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。
|
||||
* 明细一行 = 仓|内部单号|线上单号|快递|创建 HH:mm|已等 X 小时(业务对账用)。
|
||||
* (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明)
|
||||
* 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。
|
||||
*
|
||||
* 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。
|
||||
* 推送成功才写状态;发送失败绝不写,否则业务配好通道后反而不推了。
|
||||
*
|
||||
* 时区:生产容器是 UTC,一切「今天 / 现在几点」都用 Asia/Shanghai 判。
|
||||
*/
|
||||
|
||||
const cron = require('node-cron');
|
||||
const {
|
||||
getShipTimeliness,
|
||||
DEFAULT_DETAIL_LIMIT,
|
||||
EXCLUDED_CARRIERS,
|
||||
} = require('../services/warehouseShipTimelinessService');
|
||||
const { ROUNDS, beijingNow, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
||||
const {
|
||||
DEFAULT_MAX_DETAIL_ROWS,
|
||||
DEFAULT_PAGE_URL,
|
||||
DEFAULT_SEND_TIME_AFTERNOON,
|
||||
DEFAULT_SEND_TIME_NOON,
|
||||
buildPushSlot,
|
||||
getShipTimelinessNotifyRuntimeConfig,
|
||||
isShipTimelinessNotifyReady,
|
||||
loadShipTimelinessNotifyState,
|
||||
saveShipTimelinessNotifyState,
|
||||
trimSentMap,
|
||||
} = require('../services/warehouseShipTimelinessNotifySettings');
|
||||
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const DEFAULT_TICK_CRON = '* * * * *';
|
||||
// 钉钉 text 上限 20000 字节;留 300 字节给尾巴,避免临界失败
|
||||
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
||||
// 启动补推窗口:后端在 13:05 那一刻没跑(重启 / 部署),30 分钟内起来还补一次
|
||||
const CATCH_UP_WINDOW_MINUTES = 30;
|
||||
|
||||
let tickRunning = false;
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 北京时间的 YYYY-MM-DD */
|
||||
function chinaDateKey(date = new Date()) {
|
||||
const base = date instanceof Date ? date : new Date(date);
|
||||
if (!Number.isFinite(base.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(base);
|
||||
}
|
||||
|
||||
/** 北京时间的 HH:mm —— 用来跟配置里的推送时刻比对 */
|
||||
function chinaHourMinute(date = new Date()) {
|
||||
const base = date instanceof Date ? date : new Date(date);
|
||||
if (!Number.isFinite(base.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TIMEZONE,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(base);
|
||||
}
|
||||
|
||||
/** 北京时间的 HH:mm 换算成「当天第几分钟」,方便做 +/- 比较 */
|
||||
function minutesOfDay(date = new Date()) {
|
||||
const text = chinaHourMinute(date);
|
||||
if (!text) return null;
|
||||
const [hour, minute] = text.split(':').map((part) => Number(part));
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
function parseSendTime(text) {
|
||||
const [hour, minute] = String(text || '').split(':').map((part) => Number(part));
|
||||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
/** 字段清洗:换行 / 连续空白压成单空格(避免一条明细被劈成两行) */
|
||||
function cleanCell(value, fallback = '') {
|
||||
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
/** 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」,与页面同一套写法 */
|
||||
function formatOverdue(minutes) {
|
||||
const m = Number(minutes);
|
||||
if (!Number.isFinite(m) || m <= 0) return '—';
|
||||
if (m < 60) return `${m} 分钟`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rest = m % 60;
|
||||
if (h < 24) return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
|
||||
const d = Math.floor(h / 24);
|
||||
const hr = h % 24;
|
||||
return hr ? `${d} 天 ${hr} 小时` : `${d} 天`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起算时间(= 审核时间 `last_oa_confirm_time`)的展示:
|
||||
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
||||
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
|
||||
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
|
||||
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
|
||||
*
|
||||
* ⚠️ 传进来的 `auditTime` 是服务层的 benchmarkAt:审核时间,取不到时回退订单创建时间
|
||||
* (此时 row.benchmarkSource === 'created',调用方会给时间加 `*`)。
|
||||
*/
|
||||
function auditWhen(auditTime, roundDate) {
|
||||
const text = cleanCell(auditTime);
|
||||
if (!text) return '';
|
||||
const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||||
if (!hit) {
|
||||
const clock = text.match(/(\d{2}:\d{2})/);
|
||||
return clock ? clock[1] : text;
|
||||
}
|
||||
const [, , month, day, hour, minute] = hit;
|
||||
const sameDay = Boolean(roundDate) && String(roundDate).slice(5, 10) === `${month}-${day}`;
|
||||
return sameDay ? `${hour}:${minute}` : `${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
|
||||
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
||||
* 两者业务都要,别只留一个。
|
||||
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
||||
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
||||
* 不用再回页面一单单点开确认。
|
||||
* 🔥 2026-10-28 第五段是「审核」时间(起算点),不是订单创建时间;
|
||||
* 审核时间缺失、回退订单创建时间的单,时间后面带 `*`。
|
||||
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
|
||||
*/
|
||||
function buildDetailLine(row, index, roundDate) {
|
||||
const when = auditWhen(row?.auditTime, roundDate);
|
||||
const auditText = when
|
||||
? `审核 ${when}${row?.benchmarkSource === 'created' ? '*' : ''}`
|
||||
: '审核 —';
|
||||
const parts = [
|
||||
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
||||
cleanCell(row?.oId, '无单号'),
|
||||
cleanCell(row?.soId, '—'),
|
||||
cleanCell(row?.logisticCompany, '—'),
|
||||
auditText,
|
||||
`已等 ${formatOverdue(row?.overdueMinutes)}`,
|
||||
];
|
||||
return `${index}. ${parts.join('|')}`;
|
||||
}
|
||||
|
||||
/** 按仓分桶(用主仓名),组间按超时单数降序,组内保持服务层原顺序(已按时长降序) */
|
||||
function bucketRowsByCenter(rows) {
|
||||
const buckets = new Map();
|
||||
const firstSeen = new Map();
|
||||
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
|
||||
const key = cleanCell(row?.centerName || row?.warehouseName, '未知仓');
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, []);
|
||||
firstSeen.set(key, index);
|
||||
}
|
||||
buckets.get(key).push(row);
|
||||
});
|
||||
return Array.from(buckets.entries())
|
||||
.map(([key, items]) => ({ key, items, index: firstSeen.get(key) }))
|
||||
.sort((a, b) => b.items.length - a.items.length || a.index - b.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按仓分配明细额度,返回已按仓聚集的行。
|
||||
* 同 WLE 的理由:某仓超时特别多时会一口气吃满额度,其它仓一条都看不到。
|
||||
* ① 仓数 ≥ 额度 → 每仓 1 条;
|
||||
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法)。
|
||||
*/
|
||||
function pickDetailRows(rows, limit) {
|
||||
const groups = bucketRowsByCenter(rows);
|
||||
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
|
||||
const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total));
|
||||
|
||||
if (quota === 0) return [];
|
||||
if (quota >= total) return groups.flatMap((group) => group.items);
|
||||
if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]);
|
||||
|
||||
const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 }));
|
||||
const remaining = quota - groups.length;
|
||||
const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0);
|
||||
|
||||
if (remaining > 0 && leftTotal > 0) {
|
||||
alloc.forEach((item) => {
|
||||
item.exact = (item.left / leftTotal) * remaining;
|
||||
item.extra = Math.min(item.left, Math.floor(item.exact));
|
||||
});
|
||||
let assigned = alloc.reduce((sum, item) => sum + item.extra, 0);
|
||||
[...alloc]
|
||||
.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))
|
||||
.forEach((item) => {
|
||||
if (assigned >= remaining || item.extra >= item.left) return;
|
||||
item.extra += 1;
|
||||
assigned += 1;
|
||||
});
|
||||
alloc.forEach((item) => {
|
||||
item.take += item.extra;
|
||||
});
|
||||
}
|
||||
|
||||
return alloc.flatMap((item) => item.group.items.slice(0, item.take));
|
||||
}
|
||||
|
||||
/** 按仓汇总块:只列超时 > 0 的仓,降序,最多 12 行 */
|
||||
function buildCenterLines(data = {}) {
|
||||
return (data.centers || [])
|
||||
.map((center) => ({
|
||||
name: cleanCell(center.name || center.key, '未知仓'),
|
||||
overdue: Number(center.overdueUnshipped) || 0,
|
||||
oldestDays: Number(center.oldestDays) || 0,
|
||||
}))
|
||||
.filter((item) => item.overdue > 0)
|
||||
.sort((a, b) => b.overdue - a.overdue)
|
||||
.slice(0, 12)
|
||||
.map((item, index) => (
|
||||
`${index + 1}. ${item.name} ${item.overdue} 单`
|
||||
+ (item.oldestDays > 0 ? `(最久 ${item.oldestDays} 天)` : '')
|
||||
));
|
||||
}
|
||||
|
||||
function buildPageLink(pageUrl, roundKey) {
|
||||
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
||||
if (!roundKey) return base;
|
||||
const glue = base.includes('?') ? '&' : '?';
|
||||
return `${base}${glue}round=${encodeURIComponent(roundKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装群消息正文(纯函数,便于单测)。
|
||||
*
|
||||
* @param {object} data getShipTimeliness 的返回
|
||||
* @param {object} options maxDetailRows / pageUrl / roundKey
|
||||
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
|
||||
*/
|
||||
function buildNotifyText(data = {}, options = {}) {
|
||||
const summary = data.summary || {};
|
||||
const round = data.round || {};
|
||||
const window = data.window || {};
|
||||
const rows = Array.isArray(data.orders) ? data.orders : [];
|
||||
const freshness = data.freshness || {};
|
||||
|
||||
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
|
||||
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
|
||||
: DEFAULT_MAX_DETAIL_ROWS;
|
||||
const roundKey = options.roundKey || round.key || '';
|
||||
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, roundKey);
|
||||
|
||||
const overdue = Number(summary.overdueUnshipped) || 0;
|
||||
const centerCount = Number(summary.overdueCenterCount) || 0;
|
||||
const oldestDays = Number(summary.oldestDays) || 0;
|
||||
const heldCount = Number(summary.heldCount) || 0;
|
||||
const roundLabel = cleanCell(round.label, '本轮核验');
|
||||
const windowEnd = String(window.end || '').slice(11, 16);
|
||||
|
||||
const head = [
|
||||
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前审核` : ''}`,
|
||||
`超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓`
|
||||
+ (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''),
|
||||
];
|
||||
|
||||
if (overdue === 0) {
|
||||
head.push('本轮没有超时未发货的订单,全部按时发出。');
|
||||
}
|
||||
if (heldCount > 0) {
|
||||
head.push(`另有 ${heldCount} 单不计考核(待审核 / 订单异常)`);
|
||||
}
|
||||
const carrierSkipped = Number(summary.carrierSkipped) || 0;
|
||||
if (carrierSkipped > 0) {
|
||||
// 快递清单由服务层给(`excludedCarriers`),别在这里写死 ——
|
||||
// 2026-10-27 加「武进安能」时就吃过一次:写死的文案忘了改,群里口径和页面不一致。
|
||||
const carriers = (Array.isArray(data.excludedCarriers) ? data.excludedCarriers : EXCLUDED_CARRIERS)
|
||||
.filter(Boolean);
|
||||
head.push(`已剔除 ${carrierSkipped} 单不考核快递${carriers.length ? `(${carriers.join(' / ')})` : ''}`);
|
||||
}
|
||||
// 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单
|
||||
if (freshness.coversDeadline === false) {
|
||||
head.push(
|
||||
`⚠️ 数据快照只到 ${String(freshness.stateMaxQuery || '').slice(11, 16) || '未知时间'},`
|
||||
+ `尚未覆盖本轮核验时刻 ${String(data.deadline || '').slice(11, 16)},超时单量偏大,请以聚水潭实时状态为准。`
|
||||
);
|
||||
}
|
||||
|
||||
const centerLines = overdue > 0 ? buildCenterLines(data) : [];
|
||||
const roundDate = String(data.date || round.date || '').slice(0, 10);
|
||||
const detailAll = pickDetailRows(rows, maxDetailRows)
|
||||
.map((row, index) => buildDetailLine(row, index + 1, roundDate));
|
||||
const wantCount = detailAll.length;
|
||||
|
||||
const buildTail = (listed) => {
|
||||
const lines = [];
|
||||
if (overdue > 0) {
|
||||
if (listed === 0) {
|
||||
lines.push('');
|
||||
lines.push(`明细共 ${overdue} 条,消息里不放,请点下面链接查看。`);
|
||||
} else if (overdue > listed) {
|
||||
lines.push(`…其余 ${overdue - listed} 条见页面。`);
|
||||
}
|
||||
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const assemble = (listed, withCenters) => {
|
||||
const blocks = [...head];
|
||||
if (withCenters && centerLines.length) {
|
||||
blocks.push('');
|
||||
blocks.push('按仓(超时单):');
|
||||
blocks.push(...centerLines);
|
||||
}
|
||||
if (listed > 0) {
|
||||
blocks.push('');
|
||||
blocks.push(`明细前 ${listed} 条:`);
|
||||
blocks.push(...detailAll.slice(0, listed));
|
||||
}
|
||||
const tail = buildTail(listed);
|
||||
if (tail.length) blocks.push(...tail);
|
||||
const text = blocks.join('\n');
|
||||
return { text, bytes: Buffer.byteLength(text, 'utf8') };
|
||||
};
|
||||
|
||||
// 超长就从明细末尾往前砍(先砍明细,再砍分仓块)
|
||||
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」
|
||||
let listed = wantCount;
|
||||
let withCenters = centerLines.length > 0;
|
||||
let result = assemble(listed, withCenters);
|
||||
let truncated = false;
|
||||
|
||||
while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) {
|
||||
listed -= 1;
|
||||
truncated = true;
|
||||
result = assemble(listed, withCenters);
|
||||
}
|
||||
if (result.bytes > SAFE_CONTENT_BYTES && withCenters) {
|
||||
withCenters = false;
|
||||
result = assemble(listed, withCenters);
|
||||
}
|
||||
if (result.bytes > SAFE_CONTENT_BYTES) {
|
||||
const chars = Array.from(result.text);
|
||||
let bytes = 0;
|
||||
let cut = '';
|
||||
for (const char of chars) {
|
||||
const size = Buffer.byteLength(char, 'utf8');
|
||||
if (bytes + size > SAFE_CONTENT_BYTES) break;
|
||||
bytes += size;
|
||||
cut += char;
|
||||
}
|
||||
result = { text: cut, bytes };
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
rowCount: overdue,
|
||||
listedCount: listed,
|
||||
bytes: result.bytes,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** 取某一轮次今天的元信息(deadline / windowEnd / label) */
|
||||
function resolveRoundMeta(roundKey, now = new Date()) {
|
||||
const key = String(roundKey || '').toUpperCase();
|
||||
const target = ROUNDS.find((round) => round.key === key);
|
||||
if (!target) return null;
|
||||
const described = describeRounds(now).find((round) => round.key === key);
|
||||
if (!described) return null;
|
||||
return {
|
||||
key: target.key,
|
||||
label: described.label || target.label,
|
||||
date: described.date,
|
||||
verifyHour: target.verifyHour,
|
||||
deadline: described.deadline,
|
||||
windowEnd: described.windowEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/** 某个轮次今天该在几点推(读配置) */
|
||||
function sendTimeForRound(config, roundKey) {
|
||||
const key = String(roundKey || '').toUpperCase();
|
||||
if (key === 'AFTERNOON') return config.afternoonSendTime || DEFAULT_SEND_TIME_AFTERNOON;
|
||||
return config.noonSendTime || DEFAULT_SEND_TIME_NOON;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次推送。参数:
|
||||
* round 'NOON' | 'AFTERNOON'(不传 = 按当前时刻自动判定)
|
||||
* now 指定时间(测试用)
|
||||
* force 忽略「今天这轮已推过」(页面「立即推送」用)
|
||||
* dryRun 只组装消息不发送(预览 / 单测用)
|
||||
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
||||
*/
|
||||
async function pushShipTimelinessNotify(options = {}) {
|
||||
// 🔥 两个「现在」不能混用(生产容器 /etc/timezone = Etc/UTC):
|
||||
// · rawNow —— 真实时刻。凡是走 Intl(Asia/Shanghai) 或 toISOString 的地方用它;
|
||||
// · bjNow —— 平移成「本地 getter 读出来正好是北京时间」的 Date。
|
||||
// registry.describeRounds / resolveRoundMeta / getShipTimeliness 内部都用
|
||||
// `getDate()/getHours()` 直接读,必须喂 bjNow —— 否则北京 00:00~08:00 会取到前一天
|
||||
// (2026-10-21 生产踩过:13:00 轮算出 3895 单,实际只有 6 单)。
|
||||
const rawNow = options.now === undefined ? new Date() : new Date(options.now);
|
||||
const bjNow = beijingNow(rawNow);
|
||||
const pushDate = chinaDateKey(rawNow);
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
|
||||
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
||||
if (options.probe === true) {
|
||||
if (!isShipTimelinessNotifyReady(config)) {
|
||||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||||
}
|
||||
const delivery = await sendTextToWebhook({
|
||||
webhookUrl: config.webhookUrl,
|
||||
secret: config.webhookSecret,
|
||||
content: '【仓储中心发货时效】通知通道测试:收到这条说明 webhook 配置可用。',
|
||||
});
|
||||
return { sent: true, probe: true, delivery };
|
||||
}
|
||||
|
||||
// 轮次:显式指定优先;否则按当前时刻自动判定(13:00 前 = 没有轮次)
|
||||
const explicitRound = String(options.round || '').toUpperCase();
|
||||
let roundKey = explicitRound;
|
||||
if (!roundKey) {
|
||||
const minutes = minutesOfDay(rawNow);
|
||||
let active = null;
|
||||
for (const round of ROUNDS) {
|
||||
if (minutes >= round.verifyHour * 60) active = round;
|
||||
}
|
||||
if (!active) return { sent: false, reason: 'not_started' };
|
||||
roundKey = active.key;
|
||||
}
|
||||
|
||||
const meta = resolveRoundMeta(roundKey, bjNow);
|
||||
if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey };
|
||||
|
||||
// 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick)
|
||||
const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16));
|
||||
if (deadlineMinutes != null && minutesOfDay(rawNow) < deadlineMinutes) {
|
||||
return { sent: false, reason: 'before_deadline', round: roundKey, deadline: meta.deadline };
|
||||
}
|
||||
|
||||
const slot = buildPushSlot(pushDate, roundKey);
|
||||
const state = await loadShipTimelinessNotifyState();
|
||||
const sentMap = (state && typeof state.sent === 'object' && state.sent) ? state.sent : {};
|
||||
if (sentMap[slot] && options.force !== true) {
|
||||
return {
|
||||
sent: false,
|
||||
reason: 'already_sent',
|
||||
slot,
|
||||
lastSentAt: sentMap[slot].sentAt || null,
|
||||
round: roundKey,
|
||||
};
|
||||
}
|
||||
if (!config.enabled && options.force !== true) {
|
||||
return { sent: false, reason: 'disabled', round: roundKey };
|
||||
}
|
||||
|
||||
const dryRun = options.dryRun === true;
|
||||
if (!dryRun && !isShipTimelinessNotifyReady(config)) {
|
||||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||||
}
|
||||
|
||||
// 明细条数用默认 500(= 页面同一档),这样与页面共用结果缓存:
|
||||
// 推送完业务点进页面几乎是热的,不会为了推一条消息额外扫一次订单视图。
|
||||
// summary/centers 都是全量算的,limit 只截断明细,不影响数字。
|
||||
const data = await getShipTimeliness({
|
||||
round: roundKey,
|
||||
limit: DEFAULT_DETAIL_LIMIT,
|
||||
now: bjNow,
|
||||
});
|
||||
if (data?.notStarted) {
|
||||
return { sent: false, reason: 'not_started', round: roundKey };
|
||||
}
|
||||
|
||||
const built = buildNotifyText(data, {
|
||||
maxDetailRows: config.maxDetailRows,
|
||||
pageUrl: config.pageUrl,
|
||||
roundKey,
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
sent: false,
|
||||
reason: 'dry_run',
|
||||
round: roundKey,
|
||||
roundLabel: data.round?.label || meta.label,
|
||||
deadline: data.deadline || meta.deadline,
|
||||
text: built.text,
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
summary: data.summary || null,
|
||||
freshness: data.freshness || null,
|
||||
};
|
||||
}
|
||||
|
||||
const delivery = await sendTextToWebhook({
|
||||
webhookUrl: config.webhookUrl,
|
||||
secret: config.webhookSecret,
|
||||
content: built.text,
|
||||
atAll: config.atAll === true,
|
||||
});
|
||||
|
||||
const nextSent = {
|
||||
...sentMap,
|
||||
[slot]: {
|
||||
sentAt: rawNow.toISOString(),
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
deadline: data.deadline || meta.deadline || '',
|
||||
roundLabel: data.round?.label || meta.label || '',
|
||||
},
|
||||
};
|
||||
|
||||
await saveShipTimelinessNotifyState({
|
||||
version: 1,
|
||||
sent: trimSentMap(nextSent),
|
||||
lastSentAt: rawNow.toISOString(),
|
||||
lastStatus: 'sent',
|
||||
lastError: '',
|
||||
lastRound: roundKey,
|
||||
lastRoundLabel: data.round?.label || meta.label || '',
|
||||
lastSlot: slot,
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
});
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
round: roundKey,
|
||||
roundLabel: data.round?.label || meta.label,
|
||||
slot,
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
text: built.text,
|
||||
summary: data.summary || null,
|
||||
delivery,
|
||||
};
|
||||
}
|
||||
|
||||
async function runShipTimelinessNotifyJob(source = 'scheduled', options = {}) {
|
||||
try {
|
||||
const result = await pushShipTimelinessNotify(options);
|
||||
if (result.sent) {
|
||||
console.log(
|
||||
`[WstNotify] ${source} 已推送 ${result.round}(超时 ${result.rowCount} 单,列出 ${result.listedCount} 条,${result.bytes} 字节)`
|
||||
);
|
||||
} else {
|
||||
console.log(`[WstNotify] ${source} 跳过: ${result.reason}${result.round ? ` (${result.round})` : ''}`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[WstNotify] ${source} 执行失败:`, error.message || error);
|
||||
try {
|
||||
await saveShipTimelinessNotifyState({
|
||||
...(await loadShipTimelinessNotifyState()),
|
||||
lastError: String(error.message || error).slice(0, 500),
|
||||
lastFailedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (_ignored) { /* 状态写失败不影响主流程 */ }
|
||||
return { sent: false, reason: 'error', error: error.message || String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断此刻该推哪些轮次(返回数组 —— 两个时刻万一配成同一个 HH:mm,两轮都要推)。
|
||||
* · 精确命中 HH:mm → 推
|
||||
* · 刚启动的补推窗口(sendTime ~ sendTime+30min 内)→ 推(是否重复由调用方按 slot 去重)
|
||||
* 之所以不用固定 cron:业务在页面上改时刻后要立即生效,不能等重启。
|
||||
*/
|
||||
function resolveDueRounds(config, now = new Date(), { catchUp = false } = {}) {
|
||||
const minutes = minutesOfDay(now);
|
||||
if (minutes == null) return [];
|
||||
|
||||
const due = [];
|
||||
for (const round of ROUNDS) {
|
||||
const target = parseSendTime(sendTimeForRound(config, round.key));
|
||||
if (target == null) continue;
|
||||
if (minutes === target) {
|
||||
due.push(round.key);
|
||||
continue;
|
||||
}
|
||||
if (catchUp && minutes > target && minutes <= target + CATCH_UP_WINDOW_MINUTES) {
|
||||
due.push(round.key);
|
||||
}
|
||||
}
|
||||
return due;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度:每分钟 tick,比对两个轮次各自的推送时刻。
|
||||
* 启动时额外做一次「补推」检查:13:05 那一刻后端没在跑(重启 / 部署)也能补上。
|
||||
*/
|
||||
function startShipTimelinessNotifyScheduler() {
|
||||
const disabled = String(process.env.DISABLE_WST_NOTIFY || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] 发货时效钉钉推送已禁用');
|
||||
return;
|
||||
}
|
||||
const expression = String(process.env.WST_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
||||
|
||||
const tick = async ({ catchUp = false } = {}) => {
|
||||
if (tickRunning) return;
|
||||
tickRunning = true;
|
||||
try {
|
||||
const now = new Date();
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig();
|
||||
if (!config.enabled) return;
|
||||
const due = resolveDueRounds(config, now, { catchUp });
|
||||
if (!due.length) return;
|
||||
|
||||
const state = catchUp ? await loadShipTimelinessNotifyState() : null;
|
||||
for (const roundKey of due) {
|
||||
if (catchUp) {
|
||||
// 补推只在「今天这轮确实还没推过」时才动手,避免重启就把消息重发一遍
|
||||
const slot = buildPushSlot(chinaDateKey(now), roundKey);
|
||||
if (state?.sent && state.sent[slot]) continue;
|
||||
console.log(`[WstNotify] 启动补推检查命中 ${roundKey}`);
|
||||
}
|
||||
await runShipTimelinessNotifyJob(catchUp ? 'catch_up' : 'scheduled', { now, round: roundKey });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WstNotify] 定时检查失败:', error.message || error);
|
||||
} finally {
|
||||
tickRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
cron.schedule(expression, () => { tick(); }, { timezone: TIMEZONE });
|
||||
|
||||
// 启动后 45s 跑一次补推检查(等业务库/隧道就绪,别和启动预热抢)
|
||||
setTimeout(() => { tick({ catchUp: true }); }, 45 * 1000);
|
||||
|
||||
console.log(
|
||||
`[Scheduler] 发货时效钉钉推送已注册: 每分钟检查,13:00 轮→默认 13:05、18:00 轮→默认 18:05 (${TIMEZONE})`
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CATCH_UP_WINDOW_MINUTES,
|
||||
DEFAULT_TICK_CRON,
|
||||
SAFE_CONTENT_BYTES,
|
||||
TIMEZONE,
|
||||
auditWhen,
|
||||
buildCenterLines,
|
||||
buildDetailLine,
|
||||
buildNotifyText,
|
||||
buildPageLink,
|
||||
bucketRowsByCenter,
|
||||
chinaDateKey,
|
||||
chinaHourMinute,
|
||||
cleanCell,
|
||||
formatOverdue,
|
||||
minutesOfDay,
|
||||
parseSendTime,
|
||||
pickDetailRows,
|
||||
pushShipTimelinessNotify,
|
||||
resolveDueRounds,
|
||||
resolveRoundMeta,
|
||||
runShipTimelinessNotifyJob,
|
||||
sendTimeForRound,
|
||||
startShipTimelinessNotifyScheduler,
|
||||
};
|
||||
@ -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 };
|
||||
@ -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,
|
||||
};
|
||||
@ -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×tamp=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,
|
||||
};
|
||||
@ -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)后台预热,不阻塞启动
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -1,457 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常(2026-10-21 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-21):
|
||||
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心发货时效」下面),
|
||||
* 下属 12 个发货仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
|
||||
* 山东青岗集唯品仓 / 河南洛宁仓
|
||||
* 核验规则:**当日 09:00 核验前一日已发货订单的物流记录** ——
|
||||
* 昨天发了货,到今天早上 9 点物流还没有正常轨迹(没揽收 / 没物流单 / 快递报异常 /
|
||||
* 被退回 / 揽收后长时间无新轨迹),就是「物流转运异常」,按仓归口。
|
||||
*
|
||||
* ── 数据源 ────────────────────────────────────────────────────────────────
|
||||
* `rpt.jst_order_express_details`(聚水潭订单状态镜像,402 万行,视图)
|
||||
* 这是一张「订单 + 物流」合并的宽表,一单多包裹会有多行(按 logistic_id 区分)。
|
||||
* 本页用到的列:
|
||||
* send_time 发货时间(聚水潭回传)
|
||||
* logistic_status_name 物流状态:已签收 / 运输中 / 派送中 / 已揽收 /
|
||||
* 未揽收 / 无物流单 / 异常 / 拒收 / 其他
|
||||
* logistic_time 最后一条物流轨迹时间
|
||||
* last_logistic_info 最后一条轨迹描述(nvarchar 6000,SQL 里截 300 字)
|
||||
* query_time ETL 快照时间(每行最后一次被刷新的时刻)
|
||||
* ⚠️ 别用 `logistic_time_to_now_h` 判「停滞」:那是相对**查询时刻**算的,
|
||||
* 而本页的核验基准是**核验日的 09:00**(回看历史批次时会飘),统一在 Node 侧按
|
||||
* `logistic_time → deadline` 重算。
|
||||
*
|
||||
* ── 异常判定(2026-10-21 定版 · 二次收敛)────────────────────────────────
|
||||
* 业务口径:「统计的是**未揽收**和**已揽收、但没有下一步中转记录**的」,
|
||||
* 「有『运输』字样的,往后中转记录不考核」。
|
||||
* `NO_TRACKING` 无物流单 —— 发了货但没生成运单号(打单环节漏了)
|
||||
* `NOT_PICKED` 未揽收 —— 到核验时刻快递还没揽收
|
||||
* `PICKED_NO_TRANSIT` 已揽收未中转 —— 揽收后没有下一步中转/运输记录,卡在网点
|
||||
* `CARRIER_EXCEPTION` 快递报异常 —— 状态=异常(寄件人取消寄件、异常签收等)
|
||||
* `RETURNED` 退回 —— 状态=拒收,或「其他」(实测基本是「换单退回」)
|
||||
* 已签收 / 运输中 / 派送中 → 正常,不进列表。
|
||||
* 🔥 原来那套「揽收后 24h 无新轨迹 = 运输停滞」已**废弃**:
|
||||
* 干线与中转轨迹本来就不回传聚水潭,按 24h 判全是假异常。
|
||||
*
|
||||
* ── 仓库归属 ──────────────────────────────────────────────────────────────
|
||||
* 与「24 小时发货时效」同一套:按仓库名关键词**最长匹配**归主仓,子仓自动并入;
|
||||
* 「京仓-横林二仓京东云仓」业务指定排除(与发货时效页保持一致)。
|
||||
* 山东青岗集唯品仓的关键词必须比「山东青岗集」更长,否则会被青岗集仓抢走。
|
||||
*
|
||||
* ── 时区 ──────────────────────────────────────────────────────────────────
|
||||
* 🔥 生产容器是 `Etc/UTC`,DB 的 `GETDATE()` 是北京时间 —— 详见
|
||||
* warehouseShipTimelinessRegistry.beijingNow() 的说明。
|
||||
* 本模块一律用 `beijingNow()` 取「今天」,**不要**直接 `new Date()`。
|
||||
*
|
||||
* ── 批次 ──────────────────────────────────────────────────────────────────
|
||||
* 「核验批次」= 核验日 + 发货日(核验日的前一天)。
|
||||
* 默认:现在已过 09:00 → 今天这批(核验昨天发货);未到 09:00 → 昨天那批。
|
||||
* 可回看近 30 天。
|
||||
* ⚠️ 历史批次是**按当前物流快照重算**的 —— 昨天是异常、今天已经签收的单,
|
||||
* 回看时会显示成正常。页面会明确标注,别拿它当「当时的留档」。
|
||||
*/
|
||||
|
||||
const BEIJING_OFFSET_MINUTES = 480;
|
||||
|
||||
// ── 12 个发货仓(业务给定,顺序即展示顺序)────────────────────────────────
|
||||
const CENTERS = Object.freeze([
|
||||
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
|
||||
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
|
||||
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
|
||||
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
|
||||
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
|
||||
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
|
||||
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
|
||||
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
|
||||
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] },
|
||||
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌'] },
|
||||
// 🔥 关键词要比「山东青岗集」长,否则「简尚-山东青岗集仓唯品仓」会被青岗集仓抢走
|
||||
{ key: 'SD_QGJ_VIP', name: '山东青岗集唯品仓', keywords: ['山东青岗集仓唯品', '山东青岗集唯品'] },
|
||||
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] },
|
||||
]);
|
||||
|
||||
// 业务指定排除:京仓(平台仓)不算自营发货仓的考核范围(与发货时效页一致)
|
||||
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze(['京仓-横林二仓京东云仓']);
|
||||
|
||||
// ── 不参与考核的承运商(业务 2026-10-21 指定)──────────────────────────────
|
||||
// 判据 = 状态表 `logistic_company_name` **包含**关键词(不区分大小写)。
|
||||
// 🔥 「武进安能」**不能**泛化成「安能」:12 仓另有「常州安能新」3383 单/30 天,
|
||||
// 那是要正常考核的承运商,泛化会把整片正常单误剔。
|
||||
// 🔥 「中铁」「壹米滴答」这类专线物流**轨迹不回传聚水潭**,实测近 30 天
|
||||
// 中铁物流 104 单 100% 报「异常」、壹米滴答 33 单 100% 报「异常」、
|
||||
// 唯品顺丰 2510 单 2500 单报「异常」(99.6%)—— 全是假异常,必须剔。
|
||||
// 「自提」「线下」当前 12 仓实测 0 单(历史/其它仓才有),保留判定防止以后出现。
|
||||
// 🔥 「京喜京东」(含 `京喜京东快递` / `京喜京东快运`)是京喜自营那条线,
|
||||
// 实测山东青岗集仓的京喜单揽收后轨迹全停在站点、不回传聚水潭,
|
||||
// 近 30 天 62 单「已揽收未中转」全出自这里 —— 业务 2026-10-21 指定剔除。
|
||||
// 只按「京喜京东」前缀剔:`京东自营快递亿家达` / `崔桥京东特惠` / `曹县京东快运`
|
||||
// 是另外的账号,正常回传轨迹,不能连带剔掉。
|
||||
// ⚠️ 2026-10-22 业务一度要求「凡是带京东快递、京喜的都踢出」,随即**撤回该要求** ——
|
||||
// 所以这里维持只剔「京喜京东」,别把关键词放大成「京东」。
|
||||
const EXCLUDED_CARRIERS = Object.freeze([
|
||||
// 🔥 顺丰系**全部剔除**(业务 2026-10-22 定版):
|
||||
// 实测 12 仓近 30 天顺丰系共 7 个账号 —— `唯品顺丰` / `顺丰干配`(洛宁、山东、苏北大小件)/
|
||||
// `山东顺丰大件` / `景瓴十方顺丰大件-莒南`,轨迹都不按常规回传聚水潭
|
||||
// (唯品顺丰 99.6% 报异常、顺丰干配 52% 报未揽收、山东顺丰大件 67% 报未揽收)。
|
||||
// 所以这里直接用「顺丰」一个关键词全覆盖,**不再逐个列账号**。
|
||||
// 保留 `唯品顺丰` 在更前面:命中时 reason 更具体,页脚能看出是唯品那条线。
|
||||
'唯品顺丰',
|
||||
'顺丰',
|
||||
'武进安能',
|
||||
'中铁',
|
||||
'壹米滴答',
|
||||
'自提',
|
||||
'线下',
|
||||
'商家配送',
|
||||
// 京东系只剔「京喜京东」(京喜自营那条线,揽收后轨迹停在站点不回传)。
|
||||
// ⚠️ 别放大成「京东」:`京东快递` / `京东自营快递亿家达` / `京东自营快递朴美` /
|
||||
// `崔桥京东特惠` / `曹县京东快运` 等 16 个账号业务确认仍要正常考核
|
||||
// (2026-10-22 曾短暂要求全剔,随即撤回)。
|
||||
'京喜京东',
|
||||
]);
|
||||
|
||||
// ── 有条件剔除:同一承运商,只在某些仓算正常(业务 2026-10-21 追加)────────
|
||||
// 「睢宁中通」是全公司唯一的中通面单账号,开在睢宁网点。
|
||||
// · 睢宁仓自己用 → 正常揽收、轨迹完整,**照常考核**;
|
||||
// · 山东品牌仓 / 河南洛宁仓 / 崔桥工厂仓 异地用它打单 → 揽收不畅,
|
||||
// 近 30 天非睢宁仓 490 单里 163 单「未揽收」(33%),全是账号异地揽收造成的假异常。
|
||||
// 所以:`keepCenterKeys` 里的仓照常考核,其它仓命中即剔除。
|
||||
const CONDITIONAL_EXCLUDED_CARRIERS = Object.freeze([
|
||||
{
|
||||
keyword: '睢宁中通',
|
||||
keepCenterKeys: ['SN'],
|
||||
reason: '睢宁中通(非睢宁仓异地揽收)',
|
||||
},
|
||||
]);
|
||||
|
||||
// ── 已终止的订单不算转运异常(业务 2026-10-21 指定)──────────────────────
|
||||
// ① 订单状态:状态表 `order_status_name` 实测只有「已发货 / 已取消」两个值,
|
||||
// 「已取消」在近 30 天 12 仓里 8 单,其中 6 单会误报未揽收/无物流单。
|
||||
const EXCLUDED_ORDER_STATUS_KEYWORDS = Object.freeze(['取消', '作废']);
|
||||
|
||||
// ② 订单标签:`order_labels`(JSON 数组字符串)
|
||||
// · 「发货后取消」= 客户已申请退款取消,货在路上但不用再跟物流
|
||||
// · 「作废」= 单据作废
|
||||
// · 「拦截」= 买家/客服要求拦截(实测标签为「留言备注拦截」)
|
||||
// ⚠️ 别把「取消」直接当关键词匹配标签:会连带「取消预售」等无关标签。
|
||||
const EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze([
|
||||
'发货后取消',
|
||||
'作废',
|
||||
'拦截',
|
||||
]);
|
||||
|
||||
// ── 刷单 / 信誉单不算转运异常(业务 2026-10-21 追加)──────────────────────
|
||||
// 判据 = 状态表 `order_labels` 里的**订单类型标签**(不是状态、不是备注):
|
||||
// · 含「刷单」 = 刷单待处理 → 假发货,本来就不会真揽收
|
||||
// · 含「转空包」= 信誉单(信誉订单仓那批单都带这个标签,发空包不走真货)
|
||||
// 实测(近 30 天 12 仓):带「刷单」411 单,其中 148 单报未揽收 —— 全是假异常;
|
||||
// 带「转空包」0 单(都在「简尚-信誉订单仓」,那本来不在 12 个考核仓里)。
|
||||
// ⚠️ 别用「特殊单」当判据:它和补差价/配件单混在一起,剔了会漏掉真异常。
|
||||
const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
|
||||
{ keyword: '刷单', label: '刷单' },
|
||||
{ keyword: '转空包', label: '信誉单' },
|
||||
]);
|
||||
|
||||
// ── 转仓代发(业务 2026-10-23 指定不核对异常;2026-10-24 补充不计原仓发货量)──
|
||||
// 聚水潭「转仓」操作会在 `order_labels` 留一条 `转x00005` 形式的标签
|
||||
// (x + 目标仓编码)。这类单**记账仓是原仓**(如崔桥工厂仓),货却由目标仓
|
||||
// (x00005 = 曹县)实际发出 —— 承运账号也跟着变成曹县韵达小件。
|
||||
// 结果:原仓的物流状态永远停在「未揽收 / 无物流单」,报出来全是假异常。
|
||||
//
|
||||
// 实测近 30 天(12 仓):`转x00005` 438 单,报未揽收类 46 单(10.5%);
|
||||
// 已签收 371 / 无物流单 31 / 运输中 20 / 未揽收 15。
|
||||
// 快递分布:曹县韵达小件 133、邮政快递包裹POSTB 124、睢宁中通 41 ……
|
||||
// 目前只出现过 `转x00005` 一种编码,但标签是机器生成的(后续可能出现 x00006),
|
||||
// 所以用正则匹配「转x<3位以上数字>」,不写死单个编码。
|
||||
// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**。
|
||||
// 🔥 口径:**整单不计** —— 货不是从原仓走的,既不算原仓异常,也不进原仓发货分母
|
||||
// (记进分母会把分母做大、异常率被稀释)。这与「现场拦截」相反,后者货是本仓发的、留分母。
|
||||
const TRANSFER_WAREHOUSE_LABEL_PATTERN = /转\s*[xX]\s*\d{3,}/;
|
||||
|
||||
// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)────────
|
||||
// 业务原话:「把现场拦截的给去掉,也就是发的货被售后签收了」,判据是
|
||||
// **售后单有「签收单号」且「实退数量」不为 0**。
|
||||
//
|
||||
// 这是什么形态:客户不要了 / 派送时被拦下,快递当场把货拉回(轨迹常写
|
||||
// 「异常签收(拦截退回)」),货退回后由售后仓签收入库。物流环节其实已经结束,
|
||||
// 不属于「卡在网点发不出去」,报成转运异常是假警报。
|
||||
//
|
||||
// 落到库里的字段(聚水潭售后单两张表,店铺互斥,必须都读):
|
||||
// · jst_returnAndRefundInfo + jst_returnAndRefundItem (绝大多数店铺)
|
||||
// · jst_qmcust_refundlist + jst_qmcust_refundlist_item (企业微信/千牛客服单)
|
||||
// 签收单号 = 主表 `l_id`(退回那一票的运单号,实测常与原发货单号相同)
|
||||
// 实退数量 = 明细表 `r_qty`(申请数量是 `qty`,**只能看 r_qty**:
|
||||
// 拒收未签收的 r_qty=0、仅退款/补发的 r_qty 恒为 0)
|
||||
//
|
||||
// 实测(2026-10-23 批次,36 个异常包裹)命中 3 单,全是「拒收退货 + r_qty=1 +
|
||||
// l_id 非空」,和业务描述完全一致;同期 9859198/9871523 虽然 l_id 非空但
|
||||
// r_qty=0(货还在路上),正确地没被剔。
|
||||
const INTERCEPT_AFTERSALE_SOURCES = Object.freeze([
|
||||
{
|
||||
key: 'returnAndRefund',
|
||||
header: 'dbo.jst_returnAndRefundInfo',
|
||||
item: 'dbo.jst_returnAndRefundItem',
|
||||
// 主表 as_id=bigint、明细 as_id=int → 显式收窄,避免隐式转换报错
|
||||
headerAsId: 'TRY_CONVERT(int, h.as_id)',
|
||||
label: '售后单',
|
||||
},
|
||||
{
|
||||
key: 'qmcustRefund',
|
||||
header: 'dbo.jst_qmcust_refundlist',
|
||||
item: 'dbo.jst_qmcust_refundlist_item',
|
||||
// 两张表 as_id 都是 nvarchar,直接等值(别传数字 IN,会把列转 int 撞脏数据)
|
||||
headerAsId: 'h.as_id',
|
||||
label: '客服售后单',
|
||||
},
|
||||
]);
|
||||
|
||||
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
|
||||
const SORTED_KEYWORDS = CENTERS
|
||||
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
|
||||
.sort((a, b) => b.kw.length - a.kw.length);
|
||||
|
||||
// ── 核验时刻 ──────────────────────────────────────────────────────────────
|
||||
const VERIFY_HOUR = 9;
|
||||
const LOOKBACK_DAYS = 30;
|
||||
|
||||
// ── 异常类型(2026-10-21 二次定版:只看「揽收前」的两个卡点)───────────────
|
||||
// 业务原话:「这里的物流异常统计的是未揽收和已揽收、但是没有下一步中转记录的」,
|
||||
// 以及「有『运输』字样的,往后中转记录不考核,不属于物流异常」。
|
||||
// 所以:运输中 / 派送中 / 已签收 **一律正常**,不再用「24h 无新轨迹」判停滞
|
||||
// (干线/中转轨迹本来就不回传,按 24h 判只会刷屏)。
|
||||
// label 必须**自带结论**(业务要求「写上是什么异常」),不要只写「物流异常」这种笼统词。
|
||||
const EXCEPTION_TYPES = Object.freeze([
|
||||
{
|
||||
key: 'NO_TRACKING',
|
||||
label: '无物流单',
|
||||
tone: 'red',
|
||||
desc: '已发货但连运单号都没有 —— 打单/推单环节就没生成物流单',
|
||||
},
|
||||
{
|
||||
key: 'NOT_PICKED',
|
||||
label: '未揽收',
|
||||
tone: 'orange',
|
||||
desc: '有运单号,到核验时刻快递还没上门揽收',
|
||||
},
|
||||
{
|
||||
key: 'PICKED_NO_TRANSIT',
|
||||
label: '已揽收未中转',
|
||||
tone: 'gold',
|
||||
desc: '快递已揽收,但之后没有下一步中转/运输记录 —— 卡在网点没发出去',
|
||||
},
|
||||
{
|
||||
key: 'CARRIER_EXCEPTION',
|
||||
label: '快递报异常',
|
||||
tone: 'red',
|
||||
desc: '快递回传「异常」(寄件人取消寄件、异常签收等),需要人工介入',
|
||||
},
|
||||
{
|
||||
key: 'RETURNED',
|
||||
label: '退回',
|
||||
tone: 'volcano',
|
||||
desc: '拒收,或包裹已「换单退回」',
|
||||
},
|
||||
]);
|
||||
|
||||
// 「已经动起来了」的轨迹关键词:状态停在「已揽收」但最后一条轨迹里已经出现
|
||||
// 运输/中转/发往等字样 —— 说明有下一步中转记录,按业务口径**不算异常**。
|
||||
const TRANSIT_INFO_KEYWORDS = Object.freeze(['运输', '中转', '发往', '已发出', '分拨', '干线']);
|
||||
|
||||
const TYPE_MAP = new Map(EXCEPTION_TYPES.map((t) => [t.key, t]));
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「按北京时间读出来」的当前时刻(容器可能跑在 UTC)。
|
||||
* @param {Date} [base]
|
||||
* @returns {Date} 本地 getter 读出来正好是北京时间的 Date
|
||||
*/
|
||||
function beijingNow(base = new Date()) {
|
||||
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
|
||||
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 当日 00:00 的 Date(本地,不做时区换算) */
|
||||
function parseDate(text) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(String(text || '').trim());
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 仓库名 → 主仓;被排除或无法归属返回 null */
|
||||
function resolveCenter(warehouseName) {
|
||||
if (!warehouseName) return null;
|
||||
const name = String(warehouseName);
|
||||
for (const kw of EXCLUDED_WAREHOUSE_KEYWORDS) {
|
||||
if (name.includes(kw)) return null;
|
||||
}
|
||||
for (const item of SORTED_KEYWORDS) {
|
||||
if (name.includes(item.kw)) return item.center;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 承运商是否不参与考核。
|
||||
* @param {string} companyName 状态表 `logistic_company_name`
|
||||
* @param {string} [centerKey] 该行所属仓(CONDITIONAL_EXCLUDED_CARRIERS 用);
|
||||
* 不传时**保守不剔** —— 宁可少剔,不能把本仓正常单误剔
|
||||
* @returns {string|null} 命中原因/关键词;不剔除返回 null
|
||||
*/
|
||||
function matchExcludedCarrier(companyName, centerKey) {
|
||||
const name = String(companyName || '');
|
||||
if (!name) return null;
|
||||
|
||||
// ① 无条件剔除
|
||||
for (const kw of EXCLUDED_CARRIERS) {
|
||||
if (name.includes(kw)) return kw;
|
||||
}
|
||||
|
||||
// ② 有条件剔除:只在该承运商「本仓」正常时才留(如睢宁中通 → 睢宁仓)
|
||||
for (const rule of CONDITIONAL_EXCLUDED_CARRIERS) {
|
||||
if (!name.includes(rule.keyword)) continue;
|
||||
if (!centerKey) return null;
|
||||
if (rule.keepCenterKeys.includes(centerKey)) return null;
|
||||
return rule.reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单是否已终止(已取消 / 作废 / 拦截),终止的不用跟物流。
|
||||
* @param {string} orderStatus 状态表 `order_status_name`
|
||||
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
|
||||
* @returns {string|null} 命中原因(人话),不剔除返回 null
|
||||
*/
|
||||
function matchVoidedOrder(orderStatus, orderLabels) {
|
||||
const status = String(orderStatus || '');
|
||||
for (const kw of EXCLUDED_ORDER_STATUS_KEYWORDS) {
|
||||
if (status.includes(kw)) return `订单${status}`;
|
||||
}
|
||||
const labels = String(orderLabels || '');
|
||||
if (!labels) return null;
|
||||
for (const kw of EXCLUDED_ORDER_LABEL_KEYWORDS) {
|
||||
if (labels.includes(kw)) return `标签含「${kw}」`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷单 / 信誉单 → 不是真实发货,不用跟物流。
|
||||
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
|
||||
* @returns {string|null} 命中的人话原因;不剔除返回 null
|
||||
*/
|
||||
function matchNonRealOrder(orderLabels) {
|
||||
const labels = String(orderLabels || '');
|
||||
if (!labels) return null;
|
||||
for (const item of EXCLUDED_ORDER_TYPE_LABELS) {
|
||||
if (labels.includes(item.keyword)) {
|
||||
return item.label === '刷单' ? '刷单' : '信誉单(转空包)';
|
||||
}
|
||||
}
|
||||
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, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「核验批次」。
|
||||
*
|
||||
* @param {string} [dateInput] 核验日 'YYYY-MM-DD';不传按当前时刻自动判定
|
||||
* @param {Date} [nowBase] 当前时刻(真实时刻,内部会转北京时间)
|
||||
* @returns {object} 批次描述
|
||||
*/
|
||||
function resolveBatch(dateInput, nowBase = new Date()) {
|
||||
const now = beijingNow(nowBase);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||
|
||||
let verifyDay = null;
|
||||
if (dateInput) {
|
||||
const parsed = parseDate(dateInput);
|
||||
if (parsed) verifyDay = parsed;
|
||||
}
|
||||
if (!verifyDay) {
|
||||
// 未到 09:00 时,今天的批次还没跑 → 默认看昨天那批(核验前日发货)
|
||||
const beforeCheckpoint = now.getHours() < VERIFY_HOUR;
|
||||
verifyDay = beforeCheckpoint ? addDays(today, -1) : today;
|
||||
}
|
||||
// 护栏:不能晚于今天,也不能早于回看下限
|
||||
if (verifyDay > today) verifyDay = today;
|
||||
const earliest = addDays(today, -LOOKBACK_DAYS + 1);
|
||||
if (verifyDay < earliest) verifyDay = earliest;
|
||||
|
||||
const shipDay = addDays(verifyDay, -1);
|
||||
const verifyDate = formatDate(verifyDay);
|
||||
const shipDate = formatDate(shipDay);
|
||||
const deadline = `${verifyDate} ${pad2(VERIFY_HOUR)}:00:00`;
|
||||
const nowText = `${formatDate(now)} ${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
|
||||
|
||||
return {
|
||||
verifyDate,
|
||||
shipDate,
|
||||
deadline,
|
||||
windowStart: `${shipDate} 00:00:00`,
|
||||
windowEnd: `${verifyDate} 00:00:00`,
|
||||
label: `${verifyDate.slice(5)} 09:00 核验 · ${shipDate.slice(5)} 发货`,
|
||||
started: nowText >= deadline,
|
||||
isToday: verifyDate === formatDate(today),
|
||||
lookback: verifyDate !== formatDate(today),
|
||||
lookbackDays: Math.round((today - verifyDay) / 86400000),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CENTERS,
|
||||
EXCLUDED_WAREHOUSE_KEYWORDS,
|
||||
EXCLUDED_CARRIERS,
|
||||
CONDITIONAL_EXCLUDED_CARRIERS,
|
||||
EXCLUDED_ORDER_STATUS_KEYWORDS,
|
||||
EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||||
EXCLUDED_ORDER_TYPE_LABELS,
|
||||
INTERCEPT_AFTERSALE_SOURCES,
|
||||
EXCEPTION_TYPES,
|
||||
TYPE_MAP,
|
||||
TRANSIT_INFO_KEYWORDS,
|
||||
VERIFY_HOUR,
|
||||
LOOKBACK_DAYS,
|
||||
resolveCenter,
|
||||
matchExcludedCarrier,
|
||||
matchVoidedOrder,
|
||||
matchNonRealOrder,
|
||||
matchTransferWarehouse,
|
||||
TRANSFER_WAREHOUSE_LABEL_PATTERN,
|
||||
resolveBatch,
|
||||
beijingNow,
|
||||
formatDate,
|
||||
parseDate,
|
||||
addDays,
|
||||
pad2,
|
||||
};
|
||||
@ -1,625 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常 —— 服务层
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 口径见 warehouseLogisticsExceptionRegistry.js 文件头。这里只讲实现要点:
|
||||
*
|
||||
* ① 一趟查询把「核验日前一天发货」的单全部取回来(窄列 + 轨迹截 300 字),
|
||||
* Node 侧按仓库名归主仓、按物流状态判异常。实测:5400 行冷启动 ~20s、
|
||||
* 热缓存 ~4s(视图谓词下推一般,走 querySlow 给足超时)。
|
||||
* ② 一单多包裹会有多行(按 logistic_id 区分)——
|
||||
* · 明细按 (内部单号 + 运单号) 去重,仓库能看到是哪个包裹出的问题;
|
||||
* · 汇总按**内部单号**去重,否则 3 个包裹的单会被算成 3 单,异常率虚高。
|
||||
* ③ 只判「揽收前」的两个卡点(未揽收 / 已揽收未中转),见 classify()。
|
||||
* 运输中、派送中一律不考核,不再算 24h 停滞。
|
||||
* ④ 结果缓存 5 分钟:页面 10 分钟自动刷一次,点「刷新」带 force=1 绕过。
|
||||
*
|
||||
* ── 剔除(业务 2026-10-21 定版)──────────────────────────────────────────────
|
||||
* 剔掉的单**既不算异常、也不算发货分母**(否则异常率会被稀释/虚高):
|
||||
* a. 承运商 ∈ EXCLUDED_CARRIERS(顺丰系全部 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 /
|
||||
* 商家配送 / 京喜京东)—— 轨迹不回传,留着全是假异常(顺丰系各账号 52%~99.6% 假异常,
|
||||
* 中铁、壹米滴答近 30 天 100%)。「武进安能」必须精确,别泛化成「安能」。
|
||||
* a2. 有条件剔除 CONDITIONAL_EXCLUDED_CARRIERS:**「睢宁中通」只在睢宁仓考核**,
|
||||
* 其它仓异地用它打单的剔(近 30 天非睢宁仓 490 单里 163 单未揽收,全是账号异地揽收)。
|
||||
* b. 订单已终止:order_status_name 含「取消/作废」,或 order_labels 含
|
||||
* 「发货后取消 / 作废 / 拦截」—— 单据都终止了,不用再跟物流。
|
||||
* c. 刷单(标签含「刷单」)/ 信誉单(标签含「转空包」)—— 单据本身不是真实发货。
|
||||
* 各类剔除量通过 `exclusions.*` 原样返回,页面页脚展示,方便业务核对口径。
|
||||
*/
|
||||
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const registry = require('./warehouseLogisticsExceptionRegistry');
|
||||
|
||||
const DEFAULT_DETAIL_LIMIT = 500;
|
||||
// 列表上限 2000 条;导出单独放开到 5000(异常包裹一天最多几百,留足余量)
|
||||
const MAX_DETAIL_LIMIT = 2000;
|
||||
const EXPORT_DETAIL_LIMIT = 5000;
|
||||
|
||||
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
||||
const SNAPSHOT_STALE_MINUTES = 120;
|
||||
|
||||
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
|
||||
// 页面按 1000 条取、导出按 5000 条取,单槽会互相踢掉对方 —— 导出一次,
|
||||
// 页面下次自动刷新就得重查库(20~40s)。多键缓存把这个问题消掉。
|
||||
const CACHE_MAX_ENTRIES = 8;
|
||||
const resultCache = new Map();
|
||||
|
||||
function clearLogisticsExceptionCache() {
|
||||
resultCache.clear();
|
||||
}
|
||||
|
||||
/** datetime / Date → 'YYYY-MM-DD HH:mm:ss'(东八区墙钟,不做时区换算) */
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return '';
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}`
|
||||
+ ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`;
|
||||
}
|
||||
const s = String(value);
|
||||
if (!s) return '';
|
||||
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
|
||||
}
|
||||
|
||||
/** 两个时间文本相差多少分钟(to - from) */
|
||||
function diffMinutes(fromText, toText) {
|
||||
const a = formatDateTime(fromText);
|
||||
const b = formatDateTime(toText);
|
||||
if (!a || !b) return null;
|
||||
const pa = new Date(a.replace(' ', 'T')).getTime();
|
||||
const pb = new Date(b.replace(' ', 'T')).getTime();
|
||||
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return null;
|
||||
return Math.round((pb - pa) / 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按物流状态判异常类型(口径 2026-10-21 二次定版)。
|
||||
*
|
||||
* 只有两种「真异常」,其余状态一律不考核:
|
||||
* · 没揽收 —— 状态停在「未揽收」(或压根没物流单)
|
||||
* · 揽收了但没下一步中转 —— 状态停在「已揽收」,最后轨迹里也没有运输/中转字样
|
||||
*
|
||||
* 🔥 业务原话:「有『运输』字样的,往后中转记录不考核」——
|
||||
* 所以 `运输中` / `派送中` **直接算正常**;不再用「距核验时刻 ≥24h 无新轨迹」
|
||||
* 判停滞(干线、中转轨迹本就不回传聚水潭,按 24h 判全是假异常)。
|
||||
*
|
||||
* @returns {string|null} EXCEPTION_TYPES[].key;正常返回 null
|
||||
*/
|
||||
function classify(row) {
|
||||
const status = String(row.logistic_status_name || '').trim();
|
||||
const trackingNo = String(row.logistic_id || '').trim();
|
||||
|
||||
// 状态为空:有运单号但没轨迹 = 未揽收;连运单号都没有 = 无物流单
|
||||
if (!status) return trackingNo ? 'NOT_PICKED' : 'NO_TRACKING';
|
||||
|
||||
if (status === '已签收' || status === '签收') return null;
|
||||
if (status.includes('无物流')) return 'NO_TRACKING';
|
||||
if (status.includes('未揽收')) return 'NOT_PICKED';
|
||||
if (status.includes('拒收')) return 'RETURNED';
|
||||
// 「其他」实测全是「包裹已换单退回」,和拒收同性质
|
||||
if (status === '其他') return 'RETURNED';
|
||||
if (status.includes('异常')) return 'CARRIER_EXCEPTION';
|
||||
|
||||
if (status.includes('已揽收')) {
|
||||
// 揽收之后如果最后一条轨迹已经出现运输/中转/发往字样,就算「有下一步中转」
|
||||
const info = String(row.last_info || row.last_logistic_info || '');
|
||||
const moved = registry.TRANSIT_INFO_KEYWORDS.some((kw) => info.includes(kw));
|
||||
return moved ? null : 'PICKED_NO_TRANSIT';
|
||||
}
|
||||
|
||||
// 运输中 / 派送中(以及任何带「运输」字样的状态)= 已经动起来了,不考核
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 拼 SQL 的仓库名 LIKE 条件(关键词取自 CENTERS,避免手写两套名单) */
|
||||
function buildWarehouseFilters() {
|
||||
const keywords = [];
|
||||
for (const center of registry.CENTERS) {
|
||||
for (const kw of center.keywords) {
|
||||
if (!keywords.includes(kw)) keywords.push(kw);
|
||||
}
|
||||
}
|
||||
const params = {};
|
||||
const clauses = keywords.map((kw, i) => {
|
||||
params[`w${i}`] = `%${kw}%`;
|
||||
return `e.warehouse_name LIKE @w${i}`;
|
||||
});
|
||||
return { clauses, params, keywords };
|
||||
}
|
||||
|
||||
async function loadShippedPackages(batch) {
|
||||
const { clauses, params } = buildWarehouseFilters();
|
||||
const rows = await cloudDatabase.querySlow(
|
||||
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
||||
e.logistic_id, e.logistic_company_name, e.logistic_status_name,
|
||||
e.order_status_name, e.order_labels,
|
||||
e.send_time, e.logistic_time, e.query_time,
|
||||
LEFT(ISNULL(e.last_logistic_info, ''), 300) AS last_info
|
||||
FROM dbo.jst_order_express_details e
|
||||
WHERE e.send_time >= @shipStart AND e.send_time < @shipEnd
|
||||
AND (${clauses.join(' OR ')})`,
|
||||
{ ...params, shipStart: batch.windowStart, shipEnd: batch.windowEnd },
|
||||
// 视图谓词下推一般,冷启动 20s;普通池 60s 池级超时 + 重试会把耗时翻倍
|
||||
{ connectRequestTimeoutMs: 300000 }
|
||||
);
|
||||
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,
|
||||
packageCount: 0,
|
||||
exceptionOrders: 0,
|
||||
exceptionPackages: 0,
|
||||
exceptionRate: 0,
|
||||
centerCount: 0,
|
||||
byType: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 剔除口径(前端页脚直接展示,别在页面里再写一份)。
|
||||
* 两处返回(正常 / 未到核验时刻)共用,避免口径漂移。
|
||||
*/
|
||||
function exclusionMeta() {
|
||||
return {
|
||||
carriers: registry.EXCLUDED_CARRIERS,
|
||||
// 有条件剔除:「睢宁中通」只在睢宁仓考核,其它仓异地打单的剔
|
||||
conditionalCarriers: registry.CONDITIONAL_EXCLUDED_CARRIERS.map((r) => ({
|
||||
keyword: r.keyword,
|
||||
keepCenters: r.keepCenterKeys.map((key) => {
|
||||
const center = registry.CENTERS.find((c) => c.key === key);
|
||||
return center ? center.name : key;
|
||||
}),
|
||||
reason: r.reason,
|
||||
})),
|
||||
orderStatusKeywords: registry.EXCLUDED_ORDER_STATUS_KEYWORDS,
|
||||
orderLabelKeywords: registry.EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||||
// 刷单 / 信誉单的标签判据
|
||||
orderTypeLabels: registry.EXCLUDED_ORDER_TYPE_LABELS.map((t) => t.label),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存里存的是**全量明细**(不截断),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)
|
||||
const limitCap = Math.min(
|
||||
Math.max(Number(options.maxLimit) || MAX_DETAIL_LIMIT, 1),
|
||||
EXPORT_DETAIL_LIMIT
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||
limitCap
|
||||
);
|
||||
const force = options.force === true || options.force === '1' || options.force === 1;
|
||||
|
||||
const generatedAt = formatDateTime(registry.beijingNow());
|
||||
|
||||
// 未到 09:00:本批次还没跑,不拿当前快照冒充「09:00 的结论」
|
||||
if (!batch.started) {
|
||||
return {
|
||||
batch,
|
||||
notStarted: true,
|
||||
resolution: '09:00',
|
||||
summary: emptySummary(),
|
||||
centers: registry.CENTERS.map((c) => ({
|
||||
key: c.key, name: c.name, shippedOrders: 0, exceptionOrders: 0, exceptionRate: 0, byType: {},
|
||||
})),
|
||||
types: registry.EXCEPTION_TYPES,
|
||||
exclusions: {
|
||||
...exclusionMeta(),
|
||||
carrierPackages: 0,
|
||||
carrierOrders: 0,
|
||||
carrierReasons: {},
|
||||
voidedPackages: 0,
|
||||
voidedOrders: 0,
|
||||
voidedReasons: {},
|
||||
nonRealPackages: 0,
|
||||
nonRealOrders: 0,
|
||||
nonRealReasons: {},
|
||||
interceptPackages: 0,
|
||||
interceptOrders: 0,
|
||||
interceptReasons: {},
|
||||
transferPackages: 0,
|
||||
transferOrders: 0,
|
||||
transferReasons: {},
|
||||
},
|
||||
orders: [],
|
||||
detailLimit: limit,
|
||||
truncated: false,
|
||||
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
|
||||
generatedAt,
|
||||
cached: false,
|
||||
cacheAgeSec: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 键只到核验日 —— 页面(limit=1000)与导出(limit=5000)共用同一份全量结果
|
||||
const cacheKey = batch.verifyDate;
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await loadShippedPackages(batch);
|
||||
|
||||
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
|
||||
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
|
||||
const interceptedOrders = await loadInterceptedOrders(rows);
|
||||
|
||||
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||||
const centerStat = new Map();
|
||||
for (const center of registry.CENTERS) {
|
||||
centerStat.set(center.key, {
|
||||
key: center.key,
|
||||
name: center.name,
|
||||
shippedOrders: new Set(),
|
||||
exceptionOrders: new Set(),
|
||||
byType: {},
|
||||
packages: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const seenPackage = new Set();
|
||||
const exceptions = [];
|
||||
let packageCount = 0;
|
||||
const shippedOrderIds = new Set();
|
||||
let snapshotAt = '';
|
||||
|
||||
// 剔除统计(业务 2026-10-21:不考核的承运商 + 已取消/作废/拦截 + 刷单/信誉单)
|
||||
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);
|
||||
if (queryTime && queryTime > snapshotAt) snapshotAt = queryTime;
|
||||
|
||||
const center = registry.resolveCenter(row.warehouse_name);
|
||||
if (!center) continue;
|
||||
|
||||
const oId = String(row.inner_order_id || '');
|
||||
const trackingNo = String(row.logistic_id || '');
|
||||
const packageKey = `${oId}|${trackingNo || row.send_time || ''}`;
|
||||
if (seenPackage.has(packageKey)) continue;
|
||||
seenPackage.add(packageKey);
|
||||
|
||||
// ① 不参与考核的承运商(唯品顺丰 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 / 京喜京东)
|
||||
// 这些要么轨迹不回传聚水潭、要么根本没有快递轨迹,留着只会刷假异常。
|
||||
// 「睢宁中通」是有条件的:睢宁仓照常考核,其它仓异地打单的剔(传 center.key 区分)。
|
||||
const carrierHit = registry.matchExcludedCarrier(row.logistic_company_name, center.key);
|
||||
if (carrierHit) {
|
||||
skippedByCarrier.packages += 1;
|
||||
skippedByCarrier.orders.add(oId);
|
||||
skippedByCarrier.reasons[carrierHit] = (skippedByCarrier.reasons[carrierHit] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ② 已取消 / 作废 / 拦截的订单 —— 单据已终止,不用再跟物流
|
||||
const voidReason = registry.matchVoidedOrder(row.order_status_name, row.order_labels);
|
||||
if (voidReason) {
|
||||
skippedByVoid.packages += 1;
|
||||
skippedByVoid.orders.add(oId);
|
||||
skippedByVoid.reasons[voidReason] = (skippedByVoid.reasons[voidReason] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ③ 刷单 / 信誉单 —— 订单本身不是真实发货,揽不揽收不代表仓库的物流能力
|
||||
const nonRealReason = registry.matchNonRealOrder(row.order_labels);
|
||||
if (nonRealReason) {
|
||||
skippedByType.packages += 1;
|
||||
skippedByType.orders.add(oId);
|
||||
skippedByType.reasons[nonRealReason] = (skippedByType.reasons[nonRealReason] || 0) + 1;
|
||||
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;
|
||||
packageCount += 1;
|
||||
shippedOrderIds.add(oId);
|
||||
|
||||
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);
|
||||
|
||||
stat.exceptionOrders.add(oId);
|
||||
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
||||
|
||||
exceptions.push({
|
||||
oId,
|
||||
soId: String(row.so_id || ''),
|
||||
shopName: String(row.shop_name || ''),
|
||||
warehouseName: String(row.warehouse_name || ''),
|
||||
centerKey: center.key,
|
||||
centerName: center.name,
|
||||
carrier: String(row.logistic_company_name || ''),
|
||||
trackingNo,
|
||||
logisticStatus: String(row.logistic_status_name || ''),
|
||||
exceptionType: type,
|
||||
exceptionLabel: typeMeta.label,
|
||||
exceptionDesc: typeMeta.desc || '',
|
||||
tone: typeMeta.tone,
|
||||
sendTime: sendAt,
|
||||
lastLogisticTime: logisticAt,
|
||||
lastLogisticInfo: String(row.last_info || '').trim(),
|
||||
// 发货 → 核验时刻(小时,1 位小数):仓库看到「发了多久还没动静」
|
||||
pendingHours: pendingMinutes === null ? null : Math.round((pendingMinutes / 60) * 10) / 10,
|
||||
// 最后一条轨迹 → 核验时刻(小时):负数表示这条轨迹是核验之后才出现的
|
||||
staleHours: staleMinutes === null ? null : Math.round((staleMinutes / 60) * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
// 异常单最久未处理的排前面
|
||||
exceptions.sort((a, b) => (b.pendingHours || 0) - (a.pendingHours || 0));
|
||||
|
||||
// ── 汇总 ────────────────────────────────────────────────────────────
|
||||
const summary = emptySummary();
|
||||
const allExceptionOrders = new Set();
|
||||
summary.shippedOrders = shippedOrderIds.size;
|
||||
summary.packageCount = packageCount;
|
||||
|
||||
const centers = registry.CENTERS.map((center) => {
|
||||
const stat = centerStat.get(center.key);
|
||||
const byType = { ...stat.byType };
|
||||
for (const oid of stat.exceptionOrders) allExceptionOrders.add(oid);
|
||||
return {
|
||||
key: center.key,
|
||||
name: center.name,
|
||||
shippedOrders: stat.shippedOrders.size,
|
||||
exceptionOrders: stat.exceptionOrders.size,
|
||||
exceptionRate: stat.shippedOrders.size
|
||||
? Math.round((stat.exceptionOrders.size / stat.shippedOrders.size) * 1000) / 10
|
||||
: 0,
|
||||
byType,
|
||||
};
|
||||
});
|
||||
|
||||
summary.exceptionOrders = allExceptionOrders.size;
|
||||
summary.exceptionPackages = exceptions.length;
|
||||
summary.exceptionRate = summary.shippedOrders
|
||||
? Math.round((summary.exceptionOrders / summary.shippedOrders) * 1000) / 10
|
||||
: 0;
|
||||
summary.centerCount = centers.filter((c) => c.exceptionOrders > 0).length;
|
||||
for (const item of exceptions) {
|
||||
summary.byType[item.exceptionType] = (summary.byType[item.exceptionType] || 0) + 1;
|
||||
}
|
||||
|
||||
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
||||
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
||||
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
||||
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
||||
let hint = '';
|
||||
if (coversDeadline === false) {
|
||||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
||||
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
||||
} else if (stale) {
|
||||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)}(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新),`
|
||||
+ '状态可能滞后,请以聚水潭实时物流为准。';
|
||||
}
|
||||
|
||||
const payload = {
|
||||
batch,
|
||||
notStarted: false,
|
||||
resolution: '09:00',
|
||||
summary,
|
||||
centers,
|
||||
// 异常类型字典(前端图例/筛选器直接用,避免两边各写一套文案)
|
||||
types: registry.EXCEPTION_TYPES,
|
||||
// 业务指定的剔除口径(前端原样展示,别在页面里硬编码一份)
|
||||
exclusions: {
|
||||
...exclusionMeta(),
|
||||
// 本批实际剔掉了多少(包裹 / 订单 / 原因明细)
|
||||
carrierPackages: skippedByCarrier.packages,
|
||||
carrierOrders: skippedByCarrier.orders.size,
|
||||
carrierReasons: skippedByCarrier.reasons,
|
||||
voidedPackages: skippedByVoid.packages,
|
||||
voidedOrders: skippedByVoid.orders.size,
|
||||
voidedReasons: skippedByVoid.reasons,
|
||||
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,
|
||||
detailLimit: limit,
|
||||
truncated: false,
|
||||
freshness: {
|
||||
snapshotAt,
|
||||
ageMinutes,
|
||||
stale,
|
||||
coversDeadline,
|
||||
hint,
|
||||
},
|
||||
generatedAt,
|
||||
cached: false,
|
||||
cacheAgeSec: 0,
|
||||
};
|
||||
|
||||
resultCache.set(cacheKey, { at: Date.now(), data: payload });
|
||||
// 超过上限时淘汰最旧的一条(条目很少,排序开销可忽略)
|
||||
if (resultCache.size > CACHE_MAX_ENTRIES) {
|
||||
let oldestKey = '';
|
||||
let oldestAt = Infinity;
|
||||
for (const [key, entry] of resultCache.entries()) {
|
||||
if (entry.at < oldestAt) { oldestAt = entry.at; oldestKey = key; }
|
||||
}
|
||||
if (oldestKey) resultCache.delete(oldestKey);
|
||||
}
|
||||
return finalizeDetail(payload, limit, false, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:返回**不分页**的异常明细(口径与页面完全一致,只是把明细上限放开)。
|
||||
*
|
||||
* 页面列表默认只取 500 条(够看),导出必须拿全 —— 否则业务拿到的 Excel
|
||||
* 和数据对不上,是最容易被投诉的点。这里复用 `getLogisticsExceptions`,
|
||||
* 不另写一套查询,避免「导出和页面两套口径」。
|
||||
*
|
||||
* @returns {Promise<{rows:Array,total:number,truncated:boolean,batch:object}>}
|
||||
*/
|
||||
async function getLogisticsExceptionExportRows(options = {}) {
|
||||
const data = await getLogisticsExceptions({
|
||||
...options,
|
||||
limit: EXPORT_DETAIL_LIMIT,
|
||||
maxLimit: EXPORT_DETAIL_LIMIT,
|
||||
});
|
||||
const rows = Array.isArray(data.orders) ? data.orders : [];
|
||||
const total = Number(data.summary?.exceptionPackages) || rows.length;
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
// 异常包裹数超过上限时如实告知,页面会提示「结果已截断」
|
||||
truncated: rows.length < total,
|
||||
detailLimit: EXPORT_DETAIL_LIMIT,
|
||||
batch: data.batch,
|
||||
notStarted: data.notStarted,
|
||||
summary: data.summary,
|
||||
centers: data.centers,
|
||||
types: data.types,
|
||||
exclusions: data.exclusions,
|
||||
freshness: data.freshness,
|
||||
generatedAt: data.generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLogisticsExceptions,
|
||||
getLogisticsExceptionExportRows,
|
||||
clearLogisticsExceptionCache,
|
||||
classify,
|
||||
formatDateTime,
|
||||
diffMinutes,
|
||||
};
|
||||
@ -1,373 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「仓储中心发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-27):
|
||||
* 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」
|
||||
* ⟹ **一天两推**,各自绑一个核验轮次:
|
||||
* NOON 13:00 核验(窗口上界 07:00 前审核)→ 默认 13:05 推
|
||||
* AFTERNOON 18:00 核验(窗口上界 16:00 前审核)→ 默认 18:05 推
|
||||
* ⚠️ 默认给到「核验时刻 + 5 分钟」而不是整点:状态镜像 `query_time` 要略晚于核验时刻,
|
||||
* 否则 `freshness.coversDeadline=false`(数据没覆盖本轮核验点),推出去的超时数必然偏大。
|
||||
* 业务要改时刻在页面上改,不必重启后端。
|
||||
*
|
||||
* 通道 = 钉钉群自定义机器人 webhook(与「物流转运异常」同一套,只发 text、不发附件)。
|
||||
* 配置分两层存(与 yydNotificationCenterSettings 同一套做法):
|
||||
* · 普通字段 → 本地库 `system_config`,key = warehouse_ship_timeliness_notify_config
|
||||
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
|
||||
*
|
||||
* 状态另存一份(key = warehouse_ship_timeliness_notify_state),按 `<日期>|<轮次键>` 去重 ——
|
||||
* 服务重启、每分钟 tick 重复触发都不会重复轰炸;页面「立即推送」才强制重发。
|
||||
*/
|
||||
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getResilientSecret,
|
||||
saveResilientSecret,
|
||||
} = require('./resilientSecretStore');
|
||||
|
||||
const CONFIG_KEY = 'warehouse_ship_timeliness_notify_config';
|
||||
const SECRET_KEY = 'warehouse_ship_timeliness_notify_webhook_secret';
|
||||
const STATE_KEY = 'warehouse_ship_timeliness_notify_state';
|
||||
|
||||
// 两个轮次的默认推送时刻(核验 13:00 / 18:00 + 5 分钟,见文件头说明)
|
||||
const DEFAULT_SEND_TIME_NOON = '13:05';
|
||||
const DEFAULT_SEND_TIME_AFTERNOON = '18:05';
|
||||
|
||||
// 明细条数:text 上限 20000 字节,30 条中文明细约 9~12KB,留足余量
|
||||
const DEFAULT_MAX_DETAIL_ROWS = 30;
|
||||
const MAX_DETAIL_ROWS_LIMIT = 50;
|
||||
|
||||
// 页面地址(消息末尾「查看全部」用)。生产入口带 8443,别写成裸域名
|
||||
const DEFAULT_PAGE_URL = process.env.WST_PAGE_URL
|
||||
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness';
|
||||
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
// 状态里最多保留多少条「日期|轮次」记录(一天两条,30 天足够,避免 system_config 无限长)
|
||||
const MAX_STATE_ENTRIES = 60;
|
||||
|
||||
let cachedConfig = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
function normalizeText(value, maxLength = 512) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback = {}) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; }
|
||||
}
|
||||
|
||||
function maskSecret(value) {
|
||||
const text = normalizeText(value);
|
||||
if (!text) return '';
|
||||
if (text.length <= 8) return `${text.slice(0, 2)}******`;
|
||||
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** HH:mm(00:00 ~ 23:59);非法输入退回默认值,不抛错(避免把定时任务配挂) */
|
||||
function normalizeSendTime(value, fallback = DEFAULT_SEND_TIME_NOON) {
|
||||
const text = normalizeText(value, 5);
|
||||
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */
|
||||
function normalizeMaxDetailRows(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS;
|
||||
return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num)));
|
||||
}
|
||||
|
||||
function normalizePageUrl(value) {
|
||||
const text = normalizeText(value, 1024);
|
||||
if (!text) return DEFAULT_PAGE_URL;
|
||||
try {
|
||||
const url = new URL(text);
|
||||
if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL;
|
||||
return url.toString();
|
||||
} catch (_error) {
|
||||
return DEFAULT_PAGE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function upsertConfigRow(value) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[CONFIG_KEY]
|
||||
);
|
||||
const text = JSON.stringify(value);
|
||||
if (rows[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[text, 'json', CONFIG_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: CONFIG_KEY,
|
||||
config_value: text,
|
||||
config_type: 'json',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readConfigRow() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[CONFIG_KEY]
|
||||
);
|
||||
return safeJsonParse(rows[0]?.configValue, {});
|
||||
} catch (error) {
|
||||
console.warn('[WstNotify] 读取推送配置失败:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
|
||||
async function getShipTimelinessNotifyRuntimeConfig({ forceRefresh = false } = {}) {
|
||||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig };
|
||||
|
||||
const stored = await readConfigRow();
|
||||
const secret = await getResilientSecret(SECRET_KEY, {
|
||||
legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)],
|
||||
fallbackValues: [normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
|
||||
});
|
||||
|
||||
cachedConfig = {
|
||||
enabled: normalizeBoolean(stored.enabled, false),
|
||||
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|
||||
|| normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK, 1024),
|
||||
webhookSecret: secret.value,
|
||||
webhookSecretSource: secret.source,
|
||||
// 两轮各自的推送时刻
|
||||
noonSendTime: normalizeSendTime(
|
||||
stored.noonSendTime || stored.noon_send_time,
|
||||
DEFAULT_SEND_TIME_NOON
|
||||
),
|
||||
afternoonSendTime: normalizeSendTime(
|
||||
stored.afternoonSendTime || stored.afternoon_send_time,
|
||||
DEFAULT_SEND_TIME_AFTERNOON
|
||||
),
|
||||
maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows),
|
||||
atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false),
|
||||
pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url),
|
||||
source: Object.keys(stored).length > 0 ? 'database' : 'default',
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
|
||||
/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/
|
||||
function missingFields(config = {}) {
|
||||
const missing = [];
|
||||
if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址');
|
||||
return missing;
|
||||
}
|
||||
|
||||
function isShipTimelinessNotifyReady(config = {}) {
|
||||
return missingFields(config).length === 0;
|
||||
}
|
||||
|
||||
function toAdminConfig(config = {}) {
|
||||
return {
|
||||
enabled: config.enabled === true,
|
||||
webhookUrl: config.webhookUrl || '',
|
||||
webhookSecretConfigured: Boolean(config.webhookSecret),
|
||||
webhookSecretPreview: maskSecret(config.webhookSecret),
|
||||
webhookSecretSource: config.webhookSecretSource || 'unconfigured',
|
||||
noonSendTime: normalizeSendTime(config.noonSendTime, DEFAULT_SEND_TIME_NOON),
|
||||
afternoonSendTime: normalizeSendTime(config.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON),
|
||||
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
|
||||
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
|
||||
atAll: config.atAll === true,
|
||||
pageUrl: normalizePageUrl(config.pageUrl),
|
||||
ready: isShipTimelinessNotifyReady(config),
|
||||
missingFields: missingFields(config),
|
||||
source: config.source || 'default',
|
||||
};
|
||||
}
|
||||
|
||||
async function getShipTimelinessNotifyAdminConfig() {
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
return toAdminConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
|
||||
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
|
||||
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
|
||||
*/
|
||||
async function saveShipTimelinessNotifyConfig(input = {}) {
|
||||
const current = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512);
|
||||
const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true;
|
||||
|
||||
const config = {
|
||||
enabled: normalizeBoolean(input.enabled, current.enabled),
|
||||
webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl,
|
||||
webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret),
|
||||
noonSendTime: input.noonSendTime === undefined && input.noon_send_time === undefined
|
||||
? normalizeSendTime(current.noonSendTime, DEFAULT_SEND_TIME_NOON)
|
||||
: normalizeSendTime(input.noonSendTime ?? input.noon_send_time, DEFAULT_SEND_TIME_NOON),
|
||||
afternoonSendTime: input.afternoonSendTime === undefined && input.afternoon_send_time === undefined
|
||||
? normalizeSendTime(current.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON)
|
||||
: normalizeSendTime(input.afternoonSendTime ?? input.afternoon_send_time, DEFAULT_SEND_TIME_AFTERNOON),
|
||||
maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined
|
||||
? normalizeMaxDetailRows(current.maxDetailRows)
|
||||
: normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows),
|
||||
atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true),
|
||||
pageUrl: input.pageUrl === undefined && input.page_url === undefined
|
||||
? normalizePageUrl(current.pageUrl)
|
||||
: normalizePageUrl(input.pageUrl ?? input.page_url),
|
||||
};
|
||||
|
||||
if (config.enabled) {
|
||||
const missing = missingFields(config);
|
||||
if (missing.length) {
|
||||
throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (config.webhookUrl) {
|
||||
// 这里只做「像不像」的浅校验;真正的连通性由「测试通道」按钮验证
|
||||
const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender');
|
||||
try {
|
||||
config.webhookUrl = normalizeWebhookUrl(config.webhookUrl);
|
||||
} catch (error) {
|
||||
throw Object.assign(new Error(error.message), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret);
|
||||
await upsertConfigRow({
|
||||
enabled: config.enabled,
|
||||
webhookUrl: config.webhookUrl,
|
||||
noonSendTime: config.noonSendTime,
|
||||
afternoonSendTime: config.afternoonSendTime,
|
||||
maxDetailRows: config.maxDetailRows,
|
||||
atAll: config.atAll,
|
||||
pageUrl: config.pageUrl,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
cachedConfig = {
|
||||
...config,
|
||||
webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')),
|
||||
source: 'database',
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return toAdminConfig(cachedConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送状态。
|
||||
* 结构(version 1):
|
||||
* {
|
||||
* version: 1,
|
||||
* sent: { '2026-10-27|NOON': { sentAt, rowCount, listedCount, bytes, deadline } , ... },
|
||||
* lastSentAt, lastStatus, lastError, lastFailedAt, lastRoundLabel
|
||||
* }
|
||||
* sent 只保留最近 MAX_STATE_ENTRIES 条(一天两轮,30 天足够)。
|
||||
*/
|
||||
async function loadShipTimelinessNotifyState() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[STATE_KEY]
|
||||
);
|
||||
const state = safeJsonParse(rows[0]?.configValue, {});
|
||||
if (!state || typeof state !== 'object') return {};
|
||||
return { ...state, sent: (state.sent && typeof state.sent === 'object') ? state.sent : {} };
|
||||
} catch (error) {
|
||||
console.warn('[WstNotify] 读取推送状态失败:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 去重键:`<北京日期>|<轮次键>` —— 一天两轮各自独立 */
|
||||
function buildPushSlot(dateKey, roundKey) {
|
||||
const date = normalizeText(dateKey, 10);
|
||||
const round = normalizeText(roundKey, 20).toUpperCase();
|
||||
if (!date || !round) return '';
|
||||
return `${date}|${round}`;
|
||||
}
|
||||
|
||||
async function saveShipTimelinessNotifyState(next = {}) {
|
||||
const value = JSON.stringify(next && typeof next === 'object' ? next : {});
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[STATE_KEY]
|
||||
);
|
||||
if (rows[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[value, 'json', STATE_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: STATE_KEY,
|
||||
config_value: value,
|
||||
config_type: 'json',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WstNotify] 保存推送状态失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 sent 裁剪到 MAX_STATE_ENTRIES 条,按 slot 日期倒序保留(新日期优先) */
|
||||
function trimSentMap(sent = {}) {
|
||||
const entries = Object.entries(sent || {});
|
||||
if (entries.length <= MAX_STATE_ENTRIES) return sent;
|
||||
entries.sort((a, b) => {
|
||||
const da = String(a[1]?.sentAt || '');
|
||||
const db = String(b[1]?.sentAt || '');
|
||||
return db.localeCompare(da);
|
||||
});
|
||||
return Object.fromEntries(entries.slice(0, MAX_STATE_ENTRIES));
|
||||
}
|
||||
|
||||
function clearShipTimelinessNotifyConfigCache() {
|
||||
cachedConfig = null;
|
||||
cachedAt = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_KEY,
|
||||
DEFAULT_MAX_DETAIL_ROWS,
|
||||
DEFAULT_PAGE_URL,
|
||||
DEFAULT_SEND_TIME_AFTERNOON,
|
||||
DEFAULT_SEND_TIME_NOON,
|
||||
MAX_DETAIL_ROWS_LIMIT,
|
||||
MAX_STATE_ENTRIES,
|
||||
SECRET_KEY,
|
||||
STATE_KEY,
|
||||
buildPushSlot,
|
||||
clearShipTimelinessNotifyConfigCache,
|
||||
getShipTimelinessNotifyAdminConfig,
|
||||
getShipTimelinessNotifyRuntimeConfig,
|
||||
isShipTimelinessNotifyReady,
|
||||
loadShipTimelinessNotifyState,
|
||||
maskSecret,
|
||||
missingFields,
|
||||
normalizeMaxDetailRows,
|
||||
normalizePageUrl,
|
||||
normalizeSendTime,
|
||||
saveShipTimelinessNotifyConfig,
|
||||
saveShipTimelinessNotifyState,
|
||||
toAdminConfig,
|
||||
trimSentMap,
|
||||
};
|
||||
@ -1,10 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效(2026-10-20 新增)
|
||||
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-20):
|
||||
* MKH-BOM 列表左侧加一栏「仓储中心发货时效」,下列 11 个仓:
|
||||
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
||||
@ -32,10 +32,7 @@
|
||||
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
||||
* 🔴 2026-10-28 现状:候选池取订单两表的 `send_date`,起算点取状态表的
|
||||
* `last_oa_confirm_time`(审核时间),**下面这段「创建时间 = 出库单 created」
|
||||
* 是老口径,仅作历史记录,不要再照着实现**。
|
||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)(已废弃)
|
||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
|
||||
* ⚠️ 不是 `order_date`(平台下单时间);
|
||||
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
||||
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
||||
@ -265,48 +262,6 @@ const OUTBOUND_TABLES = Object.freeze({
|
||||
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
|
||||
const ORDER_STATE_TABLE = 'jst_order_express_details';
|
||||
|
||||
// ── 🔥 2026-10-28 数据源切换:主数据源改成「订单 + 订单明细」────────────────
|
||||
// 业务 yanhui 2026-10-28 明确指出:「核验抓取的销售订单表是错误的,应该抓
|
||||
// `jst_Orders`(订单)+ `jst_OrderItem`(订单明细),o_id → o_id;审核时间就是
|
||||
// jst_Orders 里的创建时间」。
|
||||
//
|
||||
// 为什么原来那张状态镜像表不能用(实测证据):
|
||||
// ① `send_time` 会**漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里,
|
||||
// 订单表已有 `send_date` 的 **563 单**、status 已是 Sent 的 492 单 ——
|
||||
// 明明发了却还算超时;2026-10-28 06h/07h 那批大批量刷新后集中爆发。
|
||||
// ② 状态表对**被合并 / 拆单父单**不回写(已有 89 + 755 单),必须回订单表看 status。
|
||||
// ③ 状态表会**整批停更**:2026-10-27 11:54:59 之后 6.8 小时零写入,
|
||||
// 18:00 轮推出去 34 单里混着已发货的(业务当场发现)。
|
||||
// ④ 两张表发货时间**其实同源**:状态表 send_time 与订单表 send_date
|
||||
// 27387 单逐条比对**零偏差** ⟹ 订单表才是权威,状态表只是它的滞后副本。
|
||||
//
|
||||
// ⟹ 主查询直接打订单两表(店铺互斥必须都读),**不再依赖状态表**。
|
||||
// 仓库归属用订单表的 `wms_co_id`(不是状态表的仓名文本),更准。
|
||||
// 状态表只剩「售后已作废」一个判据,实测在「发货中+未发」里只影响 4 单,可放弃。
|
||||
const ORDER_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||
|
||||
// 订单表 `status` 的取值(聚水潭实时状态,不是下单快照 —— 这一点
|
||||
// `jst_Orders.status` 与代码历史上「status 不可信」的结论不同,见下面 CREATED 段说明):
|
||||
// Delivering 发货中 —— **唯一该考核仓库的状态**
|
||||
// WaitConfirm 待审核 —— 仓库还没接到单,单列「待审核」不计超时
|
||||
// Question 疑问/异常 —— 单列「异常待处理」不计超时
|
||||
// Sent 已发货 → 剔除
|
||||
// Cancelled 已取消(业务叫「作废」)→ 剔除
|
||||
// Split / Merged 拆单父单 / 被合并单 → 剔除(自己永不发货)
|
||||
// WaitOuterSent 等供销商|外仓发货 → 剔除(不是本仓发)
|
||||
// 近 30 天分布:Sent 132341 / Cancelled 66643 / Split 8251 / Question 2057 /
|
||||
// Merged 1854 / Delivering 1446 / WaitConfirm 190 / WaitOuterSent 45
|
||||
const ORDER_PENDING_STATUS = 'Delivering';
|
||||
const ORDER_UNAUDITED_STATUS = 'WaitConfirm';
|
||||
const ORDER_QUESTION_STATUS = 'Question';
|
||||
const ORDER_SHIPPED_STATUS = 'Sent';
|
||||
// 主查询只捞这三种(其余状态一律不进候选,SQL 侧就过滤掉,不传回 Node)
|
||||
const ORDER_CANDIDATE_STATUSES = Object.freeze([
|
||||
ORDER_PENDING_STATUS,
|
||||
ORDER_UNAUDITED_STATUS,
|
||||
ORDER_QUESTION_STATUS,
|
||||
]);
|
||||
|
||||
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
|
||||
const SHIPPING_STATUS = '发货中';
|
||||
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
|
||||
@ -321,26 +276,24 @@ const ABNORMAL_STATUS = '异常';
|
||||
// 漏掉这个排除,整个页面会被作废单淹没。
|
||||
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
||||
|
||||
// ── 起算点:聚水潭「审核时间」`last_oa_confirm_time` ────────────────────────
|
||||
// 🔥 2026-10-28 **下午二次纠偏**(业务 yanhui 拿 9837824 打回来):
|
||||
// 起算点是**审核时间**,不是订单创建时间。证据:
|
||||
// 9837824 created 10-19 23:44:22 → last_oa_confirm_time 10-28 09:58:42,
|
||||
// 积压 8 天半;仓库 09:58 审核、10:09:55 快递员就上门取件。
|
||||
// 按 created 算「超时 205 小时」= 把客服审核积压算到仓库头上。
|
||||
// 量化(10-28 13:00 轮 599 单):376 单是当天 08:10~10:30 才批量审核的,
|
||||
// 按审核时间分轮后 13:00 轮只剩 247(再剔除漏回写的已发单 → 138)。
|
||||
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
|
||||
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
|
||||
//
|
||||
// ⚠️ 上午那版注释里「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟,
|
||||
// 业务要求按创建时间算」已被推翻 —— 那句话只统计了当期正常单,
|
||||
// 长尾积压单能差 8~11 天。**别再把起算点改回 created。**
|
||||
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补:
|
||||
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。
|
||||
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51),
|
||||
// 是**同步落库时间**,用了会把起算点整体推后几小时。
|
||||
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
|
||||
// 要换掉。
|
||||
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
|
||||
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
|
||||
// 这才是聚水潭订单列表里「创建时间」那一列。
|
||||
//
|
||||
// 审核时间只存在于状态镜像表 `jst_order_express_details`(订单两表没有这个字段),
|
||||
// 由 service 层按 `inner_order_id IN (...)` 批量取;取不到时回退 created 并标 `*`。
|
||||
// 用 last 而不是 first:业务认为改单重审后重新进作业流,从最后一次审核计时
|
||||
// (代价是重复审核会冲掉已累计超时,业务已知悉)。
|
||||
// 性能(2026-10-20 实测 9,676 个候选):
|
||||
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。
|
||||
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。
|
||||
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
||||
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。
|
||||
const CREATED_BACKFILL_BATCH = 200;
|
||||
|
||||
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
|
||||
@ -354,88 +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 = '已作废';
|
||||
|
||||
/**
|
||||
* 状态镜像表 `jst_order_express_details.order_status_name` 的**终态**。
|
||||
*
|
||||
* 🔥 2026-10-29 血的教训(业务第三次报「作废单/已发单还在考核里」):
|
||||
* 订单两表**会各自停更**(当天 qmcust 停在 07:25、jst_Orders 停在 12:45),
|
||||
* 停更期间在聚水潭做的「取消 / 发货」动作,订单表里根本看不到 ——
|
||||
* 订单 status 仍是 `Delivering`、`send_date` 仍是空,于是被当成真超时。
|
||||
* 状态镜像表虽然也会停(当天 11:52),但**比订单表新鲜 4 小时**,
|
||||
* 而且 `order_status_name` 是聚水潭订单状态的直接映射。
|
||||
*
|
||||
* 实测(2026-10-29 13:00 轮 159 单):
|
||||
* · `order_status_name='已取消'` 11 单 —— 订单表的 `labels` 里**没有**「发货前售后」
|
||||
* 这个关键词(`presaleVoid` 判据完全失效),状态表的 `order_labels` 才有,且带
|
||||
* 「已打印被取消」。业务就是拿这 11 单说「作废的也在考核之内」。
|
||||
* · `order_status_name='已发货'` 0 单 —— 机制上必须有,否则停更期发的货永远判不出。
|
||||
*
|
||||
* ⚠️ **`aftersale_status_name='已作废'` 不是作废单**!那是「售后单被撤销」,
|
||||
* 订单照常发货、**仍然要考核**(近 2 天 1387 单都是这种)。别把两个混为一谈,
|
||||
* 能判「这单不用发了」的只有 `order_status_name='已取消'`。
|
||||
*/
|
||||
const STATE_ORDER_VOID_STATUS = '已取消';
|
||||
const STATE_ORDER_SHIPPED_STATUS = '已发货';
|
||||
|
||||
/**
|
||||
* 销售出库单 `status` 里**等于单据作废**的值。
|
||||
*
|
||||
* 🔥 2026-10-29 新增(业务点名 9992097 等 9 单:「在聚水潭的销售出库单里找一下这个作废单据」)。
|
||||
*
|
||||
* 聚水潭的作废会落在**出库单**上:`jst_salesOutboundInfo.status` / `jst_qmcust_salesoutbound.status`
|
||||
* 取值只有四种(近 2 天实测):`Confirmed` / `WaitConfirm` / `Delete` / `OuterConfirming`,
|
||||
* 其中 `Delete` 就是单据作废。
|
||||
*
|
||||
* 为什么必须加这一条判据(状态表 `order_status_name='已取消'` 不够):
|
||||
* · 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`、36 单是 `Question`**,
|
||||
* 这些单状态镜像表未必抓得到(它一天只抓几次),只能靠出库单判;
|
||||
* · 出库单是仓库侧最贴身的单据,作废动作一定先落在这里。
|
||||
*
|
||||
* ⚠️ **不能只看「有 Delete 就算作废」**:一个订单可能有多张出库单(拆包 / 重开),
|
||||
* 删掉旧的那张、新的还在 `WaitConfirm` —— 这种单照常要发。
|
||||
* ⟹ 判据是「**该订单没有任何非 Delete 的出库单**」,见 service 里 ioAllDeleted 的说明。
|
||||
*
|
||||
* ⚠️ 也**不能**用「没有出库单」当判据:近 3 天未发单里 **82%(1467/1790)本来就没有出库单**
|
||||
* (订单还没推下游),拿它判作废会把绝大多数正常待发单全放跑。
|
||||
*/
|
||||
const IO_VOID_STATUS = 'Delete';
|
||||
|
||||
/**
|
||||
* 状态镜像表 `logistic_status_name` 里**等于已发**的值。
|
||||
*
|
||||
* `拒收` 也算已发 —— 快递拿走过了,客户拒收是后续环节,仓库的发货动作已完成。
|
||||
* ⚠️ **`无物流单` 不算**(39/159 单是这种:单子还没录快递单,可能自提/待定),
|
||||
* `未揽收` 也不算(只是打了单,快递还没来取)。
|
||||
* ⚠️ 别拿 `logistic_time` 当发货时间:它多数是「快递员将上门取件」的预告。
|
||||
*/
|
||||
const STATE_LOGISTIC_SHIPPED_STATUSES = Object.freeze([
|
||||
'已揽收', '运输中', '派送中', '已签收', '拒收',
|
||||
]);
|
||||
|
||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||
@ -453,31 +326,6 @@ function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
// 🔥 北京时间(UTC+8)偏移,分钟
|
||||
const BEIJING_OFFSET_MINUTES = 480;
|
||||
|
||||
/**
|
||||
* 取「按北京时间读出来」的当前时刻。
|
||||
*
|
||||
* 🔥 为什么不能直接 `new Date()`(2026-10-21 生产实测踩坑):
|
||||
* 生产容器 `/etc/timezone = Etc/UTC`(date = UTC),而 MSSQL `GETDATE()` 返回**北京时间**,
|
||||
* mssql 驱动 `useUTC:false` 又把 DB 值构造成「墙钟当 UTC」的 Date。
|
||||
* 于是「服务器 now」比「库里的时间」慢 8 小时 —— **北京时间每天 00:00~08:00,
|
||||
* 服务端取到的「今天」是昨天**,轮次窗口 / 核验时刻整体错位
|
||||
* (实测 08:04 跑 13:00 轮得 overdueUnshipped=3895,本地北京时区同一轮只有 6 单)。
|
||||
*
|
||||
* 做法:把真实时刻平移成一个「本地 getter 读出来正好是北京时间」的 Date,
|
||||
* 这样 `getHours()/getDate()/formatDateTime()` 与库里的字符串可以直接比较。
|
||||
* 本机已是东八区时平移量为 0,行为不变(测试无需改)。
|
||||
*
|
||||
* @param {Date} [base] 真实时刻,默认 `new Date()`
|
||||
* @returns {Date} 本地 getter = 北京时间的 Date
|
||||
*/
|
||||
function beijingNow(base = new Date()) {
|
||||
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
|
||||
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用「关键词最长匹配」把仓库名归到主仓。
|
||||
*
|
||||
@ -544,29 +392,12 @@ async function loadCenterWarehouseMap(force = false) {
|
||||
warehouseCache = {
|
||||
at: Date.now(),
|
||||
rows: list,
|
||||
// 全量「wms_co_id → 仓名」映射(**含归不到主仓的仓**)。
|
||||
// 发货时效主查询不再 LEFT JOIN 仓表(实测 JOIN 会让执行计划从 28s 恶化到 61~105s),
|
||||
// 改为在主查询里取回 wms_co_id、在 Node 侧用这份缓存换名字。
|
||||
allNames: rows.map((r) => ({ wmsCoId: Number(r.wms_co_id), name: String(r.name || '') })),
|
||||
unmatched,
|
||||
unmatchedCount: unmatched.length,
|
||||
excluded,
|
||||
};
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 全量「wms_co_id → 仓名」映射(含归不到主仓的仓),供 service 侧做仓名还原 */
|
||||
async function loadWarehouseNameMap() {
|
||||
if (Date.now() - warehouseCache.at >= WAREHOUSE_CACHE_TTL_MS || !warehouseCache.rows.length) {
|
||||
await loadCenterWarehouseMap();
|
||||
}
|
||||
const map = new Map();
|
||||
for (const r of warehouseCache.allNames || []) {
|
||||
if (Number.isFinite(r.wmsCoId)) map.set(r.wmsCoId, r.name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function clearWarehouseCache() {
|
||||
warehouseCache = { at: 0, rows: [] };
|
||||
}
|
||||
@ -586,10 +417,10 @@ function clearWarehouseCache() {
|
||||
* 单全漏掉了。业务口径是「……然后再看历史未发的订单」——
|
||||
* 这是一个滚动累积的待发池,不按自然日切。
|
||||
*
|
||||
* @param {Date} now 北京时间(默认 `beijingNow()`,容器是 UTC 时也安全)
|
||||
* @param {Date} now 以**东八区本地时间**为准的 Date(生产/本地机器均为东八区)
|
||||
* @returns {object|null} null 表示当前不在任何核验时段内
|
||||
*/
|
||||
function resolveActiveRound(now = beijingNow()) {
|
||||
function resolveActiveRound(now = new Date()) {
|
||||
const hour = now.getHours();
|
||||
const minute = now.getMinutes();
|
||||
const minutes = hour * 60 + minute;
|
||||
@ -625,7 +456,7 @@ function resolveActiveRound(now = beijingNow()) {
|
||||
/**
|
||||
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
|
||||
*/
|
||||
function describeRounds(now = beijingNow()) {
|
||||
function describeRounds(now = new Date()) {
|
||||
const y = now.getFullYear();
|
||||
const m = pad2(now.getMonth() + 1);
|
||||
const d = pad2(now.getDate());
|
||||
@ -679,26 +510,16 @@ module.exports = {
|
||||
centerWindowEnd,
|
||||
nextRoundDeadline,
|
||||
loadCenterWarehouseMap,
|
||||
loadWarehouseNameMap,
|
||||
clearWarehouseCache,
|
||||
getWarehouseCache: () => warehouseCache,
|
||||
resolveActiveRound,
|
||||
describeRounds,
|
||||
beijingNow,
|
||||
BEIJING_OFFSET_MINUTES,
|
||||
EXCLUDED_STATUS_SQL,
|
||||
SHIPPED_STATUS,
|
||||
PENDING_STATUS_SQL,
|
||||
NOT_SPLIT_MERGED_SQL,
|
||||
OUTBOUND_TABLES,
|
||||
ORDER_STATE_TABLE,
|
||||
// 2026-10-28 起主数据源 = 订单两表(状态表退居兜底)
|
||||
ORDER_SOURCE_TABLES,
|
||||
ORDER_PENDING_STATUS,
|
||||
ORDER_UNAUDITED_STATUS,
|
||||
ORDER_QUESTION_STATUS,
|
||||
ORDER_SHIPPED_STATUS,
|
||||
ORDER_CANDIDATE_STATUSES,
|
||||
SHIPPING_STATUS,
|
||||
UNAUDITED_STATUS,
|
||||
ABNORMAL_STATUS,
|
||||
@ -706,13 +527,6 @@ module.exports = {
|
||||
CREATED_SOURCE_TABLES,
|
||||
CREATED_BACKFILL_BATCH,
|
||||
VOID_ORDER_STATUS,
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
STATE_ORDER_VOID_STATUS,
|
||||
STATE_ORDER_SHIPPED_STATUS,
|
||||
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||
IO_VOID_STATUS,
|
||||
SHIP_TIMELINESS_HOURS,
|
||||
toNumber,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,674 +0,0 @@
|
||||
/**
|
||||
* 仓储中心物流转运异常 —— 口径回归测试(2026-10-21)
|
||||
*
|
||||
* 锁定几条最容易翻车的语义:
|
||||
* ① 12 个发货仓的关键词归属:「山东青岗集仓唯品仓」不能被「山东青岗集仓」抢走;
|
||||
* 品牌仓不能被主仓抢走;「京仓-横林二仓京东云仓」业务排除;
|
||||
* 山东/河南的代发仓不能误并入。
|
||||
* ② 核验批次:09:00 核验**前一日**发货的单;未到 09:00 时默认看昨天那批,
|
||||
* 不能拿今天的空批次冒充结论。
|
||||
* ③ 异常判定:已签收正常;未揽收/无物流单/异常/拒收/其他 → 异常;
|
||||
* 运输中类只在「最后轨迹距核验时刻 ≥ 24h」才算运输停滞。
|
||||
* ④ 一单多包裹:明细按包裹保留,汇总按**内部单号**去重(异常率不能虚高)。
|
||||
* ⑤ 缓存:第二次命中缓存,force=1 必须绕过。
|
||||
*
|
||||
* ⚠️ 本文件不连数据库,纯逻辑 + 打桩。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const registry = require('../src/services/warehouseLogisticsExceptionRegistry');
|
||||
|
||||
// ── ① 仓库归属 ──────────────────────────────────────────────────────────
|
||||
test('12 个发货仓都能按关键词归位', () => {
|
||||
const cases = [
|
||||
['简尚-横林二仓', 'HL2'],
|
||||
['简尚-横林三仓', 'HL3'],
|
||||
['简尚-横林二仓品牌仓', 'HL2_BRAND'],
|
||||
['简尚-崔桥工厂仓', 'CQ'],
|
||||
['简尚-崔桥工厂品牌仓', 'CQ_BRAND'],
|
||||
['简尚-崔桥工厂半成品包装项目仓', 'CQ_WIP'],
|
||||
['简尚-新苏北仓', 'XSB'],
|
||||
['简尚-睢宁仓', 'SN'],
|
||||
['简尚-山东青岗集仓', 'SD_QGJ'],
|
||||
['简尚-山东品牌仓', 'SD_BRAND'],
|
||||
['简尚-山东青岗集仓唯品仓', 'SD_QGJ_VIP'],
|
||||
['简尚-河南洛宁仓', 'HN_LN'],
|
||||
];
|
||||
assert.equal(registry.CENTERS.length, 12);
|
||||
for (const [name, key] of cases) {
|
||||
assert.equal(registry.resolveCenter(name)?.key, key, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('唯品仓 / 品牌仓不会被主仓的关键词抢走(最长匹配)', () => {
|
||||
assert.notEqual(
|
||||
registry.resolveCenter('简尚-山东青岗集仓唯品仓').key,
|
||||
registry.resolveCenter('简尚-山东青岗集仓').key,
|
||||
'唯品仓必须独立,不能并进青岗集仓'
|
||||
);
|
||||
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓').key, 'CQ_WIP');
|
||||
});
|
||||
|
||||
test('子仓能并入主仓,无关代发仓 / 京东云仓被排除', () => {
|
||||
assert.equal(registry.resolveCenter('简尚-运营C横林二仓专用仓')?.key, 'HL2');
|
||||
assert.equal(registry.resolveCenter('简尚-分销部睢宁专用仓')?.key, 'SN');
|
||||
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
|
||||
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null, '业务指定排除');
|
||||
for (const name of ['工厂-山东菏泽森美代发仓', '工厂-河南千优代发仓', '简尚-苏州福利品仓']) {
|
||||
assert.equal(registry.resolveCenter(name), null, `${name} 不该并入任何主仓`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── ② 核验批次 ──────────────────────────────────────────────────────────
|
||||
const at = (d, h, m = 0) => new Date(2026, 9, d, h, m, 0); // 2026-10-xx 本机时区
|
||||
|
||||
test('已过 09:00 → 核验今天这批(发货日是昨天)', () => {
|
||||
const b = registry.resolveBatch(undefined, at(21, 9, 30));
|
||||
assert.equal(b.verifyDate, '2026-10-21');
|
||||
assert.equal(b.shipDate, '2026-10-20');
|
||||
assert.equal(b.deadline, '2026-10-21 09:00:00');
|
||||
assert.equal(b.windowStart, '2026-10-20 00:00:00');
|
||||
assert.equal(b.windowEnd, '2026-10-21 00:00:00');
|
||||
assert.equal(b.started, true);
|
||||
assert.equal(b.isToday, true);
|
||||
});
|
||||
|
||||
test('未到 09:00 → 今天这批还没跑,默认退回昨天那批', () => {
|
||||
const b = registry.resolveBatch(undefined, at(21, 8, 30));
|
||||
assert.equal(b.verifyDate, '2026-10-20');
|
||||
assert.equal(b.shipDate, '2026-10-19');
|
||||
assert.equal(b.started, true, '昨天的批次早就到点了');
|
||||
assert.equal(b.lookback, true);
|
||||
});
|
||||
|
||||
test('显式指定今天但还没到 09:00 → notStarted,不能给结论', async () => {
|
||||
const b = registry.resolveBatch('2026-10-21', at(21, 8, 0));
|
||||
assert.equal(b.started, false);
|
||||
});
|
||||
|
||||
test('批次护栏:未来日期压到今天,超过 30 天压到下限', () => {
|
||||
const future = registry.resolveBatch('2026-12-01', at(21, 10, 0));
|
||||
assert.equal(future.verifyDate, '2026-10-21');
|
||||
const tooOld = registry.resolveBatch('2026-01-01', at(21, 10, 0));
|
||||
assert.equal(tooOld.verifyDate, '2026-09-22', `回看下限应为今天-29天,实际 ${tooOld.verifyDate}`);
|
||||
});
|
||||
|
||||
// ── ③ 异常判定 ──────────────────────────────────────────────────────────
|
||||
const batch = registry.resolveBatch('2026-10-21', at(21, 10, 0));
|
||||
const svcPath = require.resolve('../src/services/warehouseLogisticsExceptionService');
|
||||
const { classify } = require(svcPath);
|
||||
|
||||
test('已签收 = 正常;未揽收/无物流单/异常/拒收/其他 = 异常', () => {
|
||||
const row = (o) => ({ logistic_id: 'L', logistic_status_name: '', logistic_time: null, ...o });
|
||||
assert.equal(classify(row({ logistic_status_name: '已签收' }), batch), null);
|
||||
assert.equal(classify(row({ logistic_status_name: '未揽收' }), batch), 'NOT_PICKED');
|
||||
assert.equal(classify(row({ logistic_status_name: '无物流单' }), batch), 'NO_TRACKING');
|
||||
assert.equal(classify(row({ logistic_status_name: '异常' }), batch), 'CARRIER_EXCEPTION');
|
||||
assert.equal(classify(row({ logistic_status_name: '拒收' }), batch), 'RETURNED');
|
||||
// 「其他」实测全是「包裹已换单退回」
|
||||
assert.equal(classify(row({ logistic_status_name: '其他' }), batch), 'RETURNED');
|
||||
});
|
||||
|
||||
test('运输中 / 派送中一律不考核(有「运输」字样,往后中转记录不看)', () => {
|
||||
// 2026-10-21 二次定版:业务明确「有『运输』字样的,往后中转记录不考核」
|
||||
assert.equal(classify({
|
||||
logistic_id: 'L', logistic_status_name: '运输中', logistic_time: '2026-10-20 08:00:00',
|
||||
}), null, '运输中哪怕 25h 没新轨迹也不算异常');
|
||||
assert.equal(classify({
|
||||
logistic_id: 'L', logistic_status_name: '派送中', logistic_time: '2026-10-20 08:00:00',
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('已揽收:没有下一步中转才算异常,轨迹里出现运输/中转字样就算动起来了', () => {
|
||||
const picked = (info) => classify({
|
||||
logistic_id: 'L', logistic_status_name: '已揽收', logistic_time: '2026-10-21 04:00:00',
|
||||
last_info: info,
|
||||
});
|
||||
// 只有「已揽收/已收取快件/库存中」,没有任何中转 → 卡在网点
|
||||
assert.equal(picked('京东快递 已收取快件'), 'PICKED_NO_TRANSIT');
|
||||
assert.equal(picked('中通快运【简尚家居】已揽收'), 'PICKED_NO_TRANSIT');
|
||||
assert.equal(picked(''), 'PICKED_NO_TRANSIT');
|
||||
// 轨迹里已经有运输/中转/发往 → 有下一步中转记录,不考核
|
||||
assert.equal(picked('快件已发往【常州中转部】'), null);
|
||||
assert.equal(picked('运输中,预计明日送达'), null);
|
||||
});
|
||||
|
||||
test('状态为空时按运单号区分:有号=未揽收,无号=无物流单', () => {
|
||||
assert.equal(classify({ logistic_id: 'L1', logistic_status_name: '' }, batch), 'NOT_PICKED');
|
||||
assert.equal(classify({ logistic_id: '', logistic_status_name: '' }, batch), 'NO_TRACKING');
|
||||
});
|
||||
|
||||
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
|
||||
function stubDb(rows, options = {}) {
|
||||
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] = {
|
||||
id: dbPath,
|
||||
filename: dbPath,
|
||||
loaded: true,
|
||||
exports: { query: handler, querySlow: handler },
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
const mk = (o) => ({
|
||||
so_id: 'SO', inner_order_id: 1, shop_name: '测试店', warehouse_name: '简尚-崔桥工厂仓',
|
||||
logistic_id: 'L1', logistic_company_name: '中通', logistic_status_name: '已签收',
|
||||
send_time: '2026-10-20 10:00:00', logistic_time: '2026-10-20 18:00:00',
|
||||
query_time: '2026-10-21 09:05:00', last_info: '', ...o,
|
||||
});
|
||||
|
||||
const rows = [
|
||||
// 1:一单两包裹 —— 一个未揽收、一个已签收 → 该单算异常,但只占 1 个「单」
|
||||
mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1A', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1B', logistic_status_name: '已签收' }),
|
||||
// 2:山东青岗集仓唯品仓 · 异常
|
||||
mk({ inner_order_id: 2, so_id: 'SO-2', warehouse_name: '简尚-山东青岗集仓唯品仓',
|
||||
logistic_id: 'L2', logistic_status_name: '异常', logistic_time: '2026-10-20 20:00:00',
|
||||
send_time: '2026-10-20 12:00:00' }),
|
||||
// 3:横林二仓品牌仓 · 拒收
|
||||
mk({ inner_order_id: 3, so_id: 'SO-3', warehouse_name: '简尚-横林二仓品牌仓',
|
||||
logistic_id: 'L3', logistic_status_name: '拒收', logistic_time: '2026-10-21 07:00:00',
|
||||
send_time: '2026-10-20 15:00:00' }),
|
||||
// 4:京东云仓 —— 业务排除,不进任何主仓
|
||||
mk({ inner_order_id: 4, so_id: 'SO-4', warehouse_name: '京仓-横林二仓京东云仓',
|
||||
logistic_id: 'L4', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 5:代发仓 —— 不归属任何主仓
|
||||
mk({ inner_order_id: 5, so_id: 'SO-5', warehouse_name: '工厂-山东菏泽森美代发仓',
|
||||
logistic_id: 'L5', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 6:新苏北仓 · 已揽收后没有任何中转记录(最后轨迹 10-20 02:00)→ 已揽收未中转
|
||||
mk({ inner_order_id: 6, so_id: 'SO-6', warehouse_name: '简尚-新苏北仓',
|
||||
logistic_id: 'L6', logistic_status_name: '已揽收', logistic_time: '2026-10-20 02:00:00',
|
||||
send_time: '2026-10-20 01:00:00', last_info: '京东快递 已收取快件' }),
|
||||
// 7:睢宁仓 · 运输中 → 已动起来,不考核
|
||||
mk({ inner_order_id: 7, so_id: 'SO-7', warehouse_name: '简尚-睢宁仓',
|
||||
logistic_id: 'L7', logistic_status_name: '运输中', logistic_time: '2026-10-21 04:00:00',
|
||||
send_time: '2026-10-20 09:00:00' }),
|
||||
// 8:山东青岗集仓 · 无物流单
|
||||
mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓',
|
||||
logistic_id: '', logistic_status_name: '无物流单', logistic_time: null,
|
||||
send_time: '2026-10-20 11:00:00' }),
|
||||
// 9:横林三仓 · 已签收 → 正常
|
||||
mk({ inner_order_id: 9, so_id: 'SO-9', warehouse_name: '简尚-横林三仓' }),
|
||||
// 10:完全重复行(同单同运单号)→ 必须去重
|
||||
mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓',
|
||||
logistic_id: '', logistic_status_name: '无物流单', logistic_time: null,
|
||||
send_time: '2026-10-20 11:00:00' }),
|
||||
];
|
||||
|
||||
async function withStub(fn, stubRows = rows, options = {}) {
|
||||
const realDb = require.cache[dbPath];
|
||||
const calls = stubDb(stubRows, options);
|
||||
delete require.cache[svcPath];
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
try {
|
||||
const svc = require(svcPath);
|
||||
svc.clearLogisticsExceptionCache();
|
||||
return await fn(svc, calls);
|
||||
} finally {
|
||||
if (realDb) require.cache[dbPath] = realDb;
|
||||
else delete require.cache[dbPath];
|
||||
delete require.cache[svcPath];
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
}
|
||||
}
|
||||
|
||||
test('汇总按内部单号去重,明细按包裹保留;排除仓/代发仓不进统计', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
|
||||
// 归属主仓的单:1,2,3,6,7,8,9 = 7 单;包裹 8 个(1 号单两包)
|
||||
assert.equal(data.summary.shippedOrders, 7);
|
||||
assert.equal(data.summary.packageCount, 8, '重复行要去重');
|
||||
// 异常单:1(未揽收) 2(异常) 3(拒收) 6(已揽收未中转) 8(无物流单) = 5
|
||||
assert.equal(data.summary.exceptionOrders, 5);
|
||||
assert.equal(data.summary.exceptionPackages, 5);
|
||||
assert.equal(data.summary.centerCount, 5);
|
||||
assert.equal(data.summary.exceptionRate, 71.4);
|
||||
|
||||
const typeCount = data.summary.byType;
|
||||
assert.equal(typeCount.NOT_PICKED, 1);
|
||||
assert.equal(typeCount.CARRIER_EXCEPTION, 1);
|
||||
assert.equal(typeCount.RETURNED, 1);
|
||||
assert.equal(typeCount.PICKED_NO_TRANSIT, 1);
|
||||
assert.equal(typeCount.NO_TRACKING, 1);
|
||||
|
||||
// 唯品仓独立成仓,不并进青岗集仓
|
||||
const vip = data.centers.find((c) => c.key === 'SD_QGJ_VIP');
|
||||
assert.equal(vip.shippedOrders, 1);
|
||||
assert.equal(vip.exceptionOrders, 1);
|
||||
const qgj = data.centers.find((c) => c.key === 'SD_QGJ');
|
||||
assert.equal(qgj.exceptionOrders, 1, '青岗集仓自己只有 1 单异常(无物流单那单)');
|
||||
|
||||
// 排除仓 / 代发仓不该出现在任何仓的统计里
|
||||
assert.equal(data.centers.reduce((s, c) => s + c.shippedOrders, 0), 7);
|
||||
|
||||
// 明细按「发货→核验」等得最久的排前面(6 号单 10-20 01:00 发货,到 09:00 已 32 小时)
|
||||
assert.equal(data.orders[0].oId, '6', `首行应为最久未动的单,实际 ${data.orders[0].oId}`);
|
||||
assert.equal(data.orders[0].pendingHours, 32);
|
||||
assert.equal(data.orders[0].staleHours, 31, '最后轨迹 10-20 02:00 → 距核验 31 小时');
|
||||
|
||||
// 数据源护栏:只查一张表、带发货日窗口
|
||||
assert.equal(data.freshness.coversDeadline, true);
|
||||
assert.equal(data.freshness.snapshotAt, '2026-10-21 09:05:00');
|
||||
});
|
||||
});
|
||||
|
||||
test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
|
||||
assert.equal(data.notStarted, true);
|
||||
assert.equal(data.summary.exceptionOrders, 0);
|
||||
assert.equal(calls.times, 0, '未到点不该打库');
|
||||
assert.equal(data.centers.length, 12);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const second = await svc.getLogisticsExceptions(opts);
|
||||
assert.equal(second.cached, true);
|
||||
assert.equal(mainQueries(), 1, '第二次应命中缓存');
|
||||
|
||||
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
|
||||
assert.equal(third.cached, false);
|
||||
assert.equal(mainQueries(), 2, 'force=1 必须真查库');
|
||||
});
|
||||
});
|
||||
|
||||
test('明细 limit 生效并给出截断标记', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 2 });
|
||||
assert.equal(data.orders.length, 2);
|
||||
assert.equal(data.truncated, true);
|
||||
assert.equal(data.detailLimit, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('导出:放开明细上限、返回全量异常(不受列表 2000 上限限制)', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptionExportRows({
|
||||
date: '2026-10-21',
|
||||
now: at(21, 10, 0),
|
||||
});
|
||||
assert.equal(data.detailLimit, 5000, '导出的明细上限应放到 5000');
|
||||
assert.equal(data.truncated, false);
|
||||
assert.equal(data.rows.length, data.summary.exceptionPackages, '导出行数应等于异常包裹数');
|
||||
assert.ok(data.batch?.label, '导出要带批次信息给文件名用');
|
||||
assert.ok(Array.isArray(data.exclusions?.carriers), '导出也要带剔除口径');
|
||||
// 明细字段够导出用(页面上那几列)
|
||||
const row = data.rows[0];
|
||||
for (const key of ['warehouseName', 'oId', 'soId', 'carrier', 'trackingNo',
|
||||
'logisticStatus', 'exceptionLabel', 'sendTime', 'pendingHours']) {
|
||||
assert.ok(key in row, `导出缺字段 ${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('缓存只按核验日:页面加载后导出直接命中,不再重算整轮', 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 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, '导出仍要拿到全部明细');
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
test('快照没覆盖核验时刻时给出红色告警文案', async () => {
|
||||
const staleRows = rows.map((r) => ({ ...r, query_time: '2026-10-21 06:10:00' }));
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0) });
|
||||
assert.equal(data.freshness.coversDeadline, false);
|
||||
assert.match(data.freshness.hint, /没覆盖/u);
|
||||
}, staleRows);
|
||||
});
|
||||
|
||||
// ── ⑥ 剔除口径(业务 2026-10-21 指定)───────────────────────────────────
|
||||
test('不参与考核的承运商:顺丰系全剔 + 武进安能/中铁/壹米滴答/自提/线下/商家配送/京喜京东', () => {
|
||||
const hit = (name) => registry.matchExcludedCarrier(name);
|
||||
for (const name of [
|
||||
'唯品顺丰',
|
||||
'武进安能',
|
||||
'武进安能大件',
|
||||
'中铁物流',
|
||||
'壹米滴答专线',
|
||||
'自提',
|
||||
'乐鑫达线下韵达配件',
|
||||
'商家配送',
|
||||
// 顺丰系:业务 2026-10-22「涉及到顺丰的快递全部剔除」,账号一个不漏
|
||||
'顺丰速运',
|
||||
'顺丰标快',
|
||||
'洛宁顺丰干配',
|
||||
'山东顺丰干配',
|
||||
'苏北顺丰干配大件',
|
||||
'苏北顺丰干配小件',
|
||||
'山东顺丰大件',
|
||||
'景瓴十方顺丰大件-莒南',
|
||||
// 京东系只有京喜自营那条线剔(10-22 曾要求全剔,业务随即撤回)
|
||||
'京喜京东快递',
|
||||
'京喜京东快运',
|
||||
]) {
|
||||
assert.ok(hit(name), `${name} 应剔除,实际未命中`);
|
||||
}
|
||||
// 「唯品顺丰」要返回更具体的原因(页脚能看出是唯品那条线),但同样是剔除
|
||||
assert.equal(hit('唯品顺丰'), '唯品顺丰');
|
||||
assert.equal(hit('山东顺丰大件'), '顺丰');
|
||||
assert.equal(hit('京喜京东快递'), '京喜京东');
|
||||
// 🔥 别把「京喜京东」放大成「京东」:这些京东系账号要正常考核
|
||||
assert.equal(hit('京东快递'), null);
|
||||
assert.equal(hit('京东自营快递'), null);
|
||||
assert.equal(hit('京东自营快递亿家达'), null);
|
||||
assert.equal(hit('京东自营快递朴美'), null);
|
||||
assert.equal(hit('崔桥京东特惠'), null);
|
||||
assert.equal(hit('曹县京东快运'), null);
|
||||
// 非顺丰/非京东的常规承运商不能误剔
|
||||
assert.equal(hit('常州中通快运'), null);
|
||||
assert.equal(hit('常州德邦'), null);
|
||||
assert.equal(hit('百世快运'), null);
|
||||
assert.equal(hit('睢宁德邦'), null);
|
||||
assert.equal(hit('曹县德邦小件'), null);
|
||||
});
|
||||
|
||||
test('🔥 睢宁中通是条件剔除:睢宁仓照常考核,其它仓异地打单的剔', () => {
|
||||
const hit = (name, centerKey) => registry.matchExcludedCarrier(name, centerKey);
|
||||
// 睢宁仓自己用 → 正常
|
||||
assert.equal(hit('睢宁中通', 'SN'), null);
|
||||
// 异地打单(实测山东品牌仓 137 单未揽收、河南洛宁 20 单、崔桥工厂 6 单)→ 剔
|
||||
assert.ok(hit('睢宁中通', 'SD_BRAND'));
|
||||
assert.ok(hit('睢宁中通', 'HN_LN'));
|
||||
assert.ok(hit('睢宁中通', 'CQ'));
|
||||
assert.ok(hit('睢宁中通', 'HL2'));
|
||||
// 没给仓(保守)→ 不剔,宁可少剔也不能误剔睢宁仓
|
||||
assert.equal(hit('睢宁中通'), null);
|
||||
// 别把「常州中通快运」等其它中通账号连带剔掉
|
||||
assert.equal(hit('常州中通快运', 'SD_BRAND'), null);
|
||||
});
|
||||
|
||||
test('🔥 「武进安能」不能泛化成「安能」:常州安能新要正常考核', () => {
|
||||
// 12 仓实测「常州安能新」3383 单/30 天,泛化「安能」会把整片正常单误剔
|
||||
assert.equal(registry.matchExcludedCarrier('常州安能新'), null);
|
||||
assert.equal(registry.matchExcludedCarrier('雅柏安能'), null);
|
||||
assert.equal(registry.matchExcludedCarrier('常州中通快运'), null);
|
||||
assert.equal(registry.matchExcludedCarrier(''), null);
|
||||
assert.equal(registry.matchExcludedCarrier(null), null);
|
||||
});
|
||||
|
||||
test('已取消 / 作废 / 拦截的订单剔除', () => {
|
||||
const hit = (status, labels) => registry.matchVoidedOrder(status, labels);
|
||||
assert.ok(hit('已取消', ''), '订单状态已取消要剔');
|
||||
assert.ok(hit('已作废', ''));
|
||||
assert.ok(hit('已发货', '["发货后取消","发货后售后"]'), '发货后取消要剔');
|
||||
assert.ok(hit('已发货', '["留言备注拦截","禁止拆分"]'), '留言备注拦截要剔');
|
||||
assert.ok(hit('已发货', '["作废"]'));
|
||||
// 正常单不能被误剔
|
||||
assert.equal(hit('已发货', '["店铺-拼多多","已成团"]'), null);
|
||||
assert.equal(hit('已发货', ''), null);
|
||||
// 「取消预售」是营销标签,不是取消,不能剔
|
||||
assert.equal(hit('已发货', '["取消预售"]'), null);
|
||||
});
|
||||
|
||||
test('剔除的单既不算异常,也不进发货分母', async () => {
|
||||
const mixed = [
|
||||
// 10:唯品顺丰 · 未揽收 → 本来会误报,必须整单消失
|
||||
mk({ inner_order_id: 10, so_id: 'SO-10', logistic_company_name: '唯品顺丰',
|
||||
logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 11:中铁物流 · 异常
|
||||
mk({ inner_order_id: 11, so_id: 'SO-11', logistic_company_name: '中铁物流',
|
||||
logistic_status_name: '异常' }),
|
||||
// 12:武进安能 · 已签收 → 连带分母也要消失
|
||||
mk({ inner_order_id: 12, so_id: 'SO-12', logistic_company_name: '武进安能' }),
|
||||
// 13:常州安能新 · 未揽收 → 必须留下(反例)
|
||||
mk({ inner_order_id: 13, so_id: 'SO-13', logistic_company_name: '常州安能新',
|
||||
logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 14:正常承运商但订单已取消 → 剔
|
||||
mk({ inner_order_id: 14, so_id: 'SO-14', logistic_company_name: '常州中通快运',
|
||||
logistic_status_name: '无物流单', logistic_time: null,
|
||||
order_status_name: '已取消' }),
|
||||
// 15:正常承运商但发货后取消 → 剔
|
||||
mk({ inner_order_id: 15, so_id: 'SO-15', logistic_company_name: '常州中通快运',
|
||||
logistic_status_name: '未揽收', logistic_time: null,
|
||||
order_labels: '["发货后取消","发货后售后"]' }),
|
||||
// 16:正常单留作对照
|
||||
mk({ inner_order_id: 16, so_id: 'SO-16', logistic_company_name: '常州中通快运' }),
|
||||
// 17:京喜京东快递 · 已揽收未中转 → 京喜自营轨迹不回传,剔
|
||||
mk({ inner_order_id: 17, so_id: 'SO-17', warehouse_name: '简尚-山东青岗集仓',
|
||||
logistic_company_name: '京喜京东快递', logistic_status_name: '已揽收', logistic_time: '2026-10-20 20:00:00',
|
||||
last_info: '快件已从【菏泽水岸城邦站】揽收完成' }),
|
||||
// 18:睢宁中通在**非睢宁仓**(山东品牌仓)未揽收 → 异地打单,剔
|
||||
mk({ inner_order_id: 18, so_id: 'SO-18', warehouse_name: '简尚-山东品牌仓',
|
||||
logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 19:睢宁中通在**睢宁仓**未揽收 → 本仓本账号,照常考核(反例,必须留)
|
||||
mk({ inner_order_id: 19, so_id: 'SO-19', warehouse_name: '简尚-睢宁仓',
|
||||
logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
];
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
|
||||
// 只剩 13、16、19 三单(10/11/12 承运商剔除,14/15 作废剔除,17 京喜、18 异地睢宁中通剔除)
|
||||
assert.equal(data.summary.shippedOrders, 3, `分母应只剩 3 单,实际 ${data.summary.shippedOrders}`);
|
||||
assert.equal(data.summary.exceptionOrders, 2, '只剩 13、19 两单异常');
|
||||
assert.equal(data.summary.exceptionRate, 66.7);
|
||||
assert.deepEqual(data.orders.map((o) => o.oId).sort(), ['13', '19']);
|
||||
|
||||
// 剔除量要如实返回,页面能展示
|
||||
assert.equal(data.exclusions.carrierOrders, 5, '唯品顺丰/中铁/武进安能/京喜京东/异地睢宁中通 5 单');
|
||||
assert.equal(data.exclusions.voidedOrders, 2, '已取消 + 发货后取消 2 单');
|
||||
assert.equal(data.exclusions.carrierPackages, 5);
|
||||
assert.equal(data.exclusions.voidedPackages, 2);
|
||||
assert.ok(data.exclusions.carrierReasons['睢宁中通(非睢宁仓异地揽收)']);
|
||||
assert.ok(data.exclusions.voidedReasons['订单已取消']);
|
||||
assert.ok(data.exclusions.voidedReasons['标签含「发货后取消」']);
|
||||
}, mixed);
|
||||
});
|
||||
|
||||
test('刷单 / 信誉单剔除(按订单标签,不按「特殊单」)', () => {
|
||||
assert.equal(registry.matchNonRealOrder('["刷单待处理","特殊单","店铺-拼多多"]'), '刷单');
|
||||
assert.equal(registry.matchNonRealOrder('["特殊单","禁止拆分","转空包发货"]'), '信誉单(转空包)');
|
||||
// 反例:「特殊单」本身混着补差价/配件单,不能当判据
|
||||
assert.equal(registry.matchNonRealOrder('["特殊单","补差价"]'), null);
|
||||
assert.equal(registry.matchNonRealOrder('["店铺-京东","晚发赔"]'), null);
|
||||
assert.equal(registry.matchNonRealOrder(''), null);
|
||||
assert.equal(registry.matchNonRealOrder(null), null);
|
||||
});
|
||||
|
||||
test('刷单的包裹不进分母也不算异常', async () => {
|
||||
const mixed = [
|
||||
// 20:刷单 · 未揽收 → 假异常,必须消失
|
||||
mk({ inner_order_id: 20, so_id: 'SO-20', logistic_status_name: '未揽收', logistic_time: null,
|
||||
order_labels: '["刷单待处理","特殊单","店铺-拼多多"]' }),
|
||||
// 21:信誉单(转空包)· 无物流单 → 消失
|
||||
mk({ inner_order_id: 21, so_id: 'SO-21', logistic_status_name: '无物流单', logistic_time: null,
|
||||
order_labels: '["特殊单","禁止拆分","转空包发货"]' }),
|
||||
// 22:带「特殊单」但不是刷单/信誉单 → 必须留下
|
||||
mk({ inner_order_id: 22, so_id: 'SO-22', 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.equal(data.summary.shippedOrders, 1, '只剩 22 号单');
|
||||
assert.deepEqual(data.orders.map((o) => o.oId), ['22']);
|
||||
assert.equal(data.exclusions.nonRealOrders, 2);
|
||||
assert.equal(data.exclusions.nonRealReasons['刷单'], 1);
|
||||
assert.equal(data.exclusions.nonRealReasons['信誉单(转空包)'], 1);
|
||||
assert.deepEqual(data.exclusions.orderTypeLabels, ['刷单', '信誉单']);
|
||||
}, 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) });
|
||||
assert.equal(data.notStarted, true);
|
||||
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')];
|
||||
}
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,294 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「仓储中心 24 小时发货时效」两轮核验完成后推钉钉群 —— 正文组装与调度回归测试
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-27):「13 点核验 7 点前的超时单,核验完成后通知;
|
||||
* 18:00 核验的订单,核验完成后也通知。」⟹ 一天两推,各绑一个轮次。
|
||||
*
|
||||
* 这里只测五件容易悄悄坏掉的事:
|
||||
* ① 两个轮次各自的推送时刻能不能正确命中(含「配成同一时刻」的边界);
|
||||
* ② 群消息正文的数字与明细(业务照着这条消息追单,数字错了没人能发现);
|
||||
* ③ 0 单超时也要推(群里看到「本轮 0 单」= 任务没挂);
|
||||
* ④ 明细按仓保底 + 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
|
||||
* ⑤ 去重键必须「日期 + 轮次」,一天两轮互不干扰。
|
||||
*
|
||||
* ⚠️ 本文件不连数据库、不发网络请求,纯逻辑测试。
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const job = require('../src/jobs/warehouseShipTimelinessNotifyJob');
|
||||
const settings = require('../src/services/warehouseShipTimelinessNotifySettings');
|
||||
|
||||
function sampleData(overrides = {}) {
|
||||
return {
|
||||
round: { key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEnd: '2026-10-27 07:00:00' },
|
||||
deadline: '2026-10-27 13:00:00',
|
||||
date: '2026-10-27',
|
||||
window: { start: '2026-10-07 00:00:00', end: '2026-10-27 07:00:00', includeHistory: true, boundary: 'audit' },
|
||||
summary: {
|
||||
overdueUnshipped: 3,
|
||||
overdueCenterCount: 2,
|
||||
oldestDays: 4,
|
||||
heldCount: 5,
|
||||
voidCount: 12,
|
||||
carrierSkipped: 2,
|
||||
},
|
||||
centers: [
|
||||
{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 2, oldestDays: 4 },
|
||||
{ key: 'HL2', name: '横林二仓', overdueUnshipped: 1, oldestDays: 1 },
|
||||
{ key: 'SN', name: '睢宁仓', overdueUnshipped: 0, oldestDays: 0 },
|
||||
],
|
||||
orders: [
|
||||
{
|
||||
oId: '9855123',
|
||||
soId: '261021-301318827670255',
|
||||
shopName: '京东旗舰店',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
centerName: '崔桥工厂仓',
|
||||
logisticCompany: '武进韵达',
|
||||
auditTime: '2026-10-27 06:32:11',
|
||||
overdueMinutes: 388,
|
||||
},
|
||||
{
|
||||
oId: '9855200',
|
||||
soId: 'SO20261027001',
|
||||
shopName: '天猫店',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
centerName: '崔桥工厂仓',
|
||||
logisticCompany: '常州安能新',
|
||||
auditTime: '2026-10-26 09:05:00',
|
||||
overdueMinutes: 2875,
|
||||
},
|
||||
{
|
||||
oId: '9855300',
|
||||
soId: '',
|
||||
shopName: '抖音店',
|
||||
warehouseName: '简尚-横林二仓',
|
||||
centerName: '横林二仓',
|
||||
// 快递名为空 → 明细里要显示「—」,不能出现 undefined
|
||||
logisticCompany: '',
|
||||
auditTime: '',
|
||||
overdueMinutes: 120,
|
||||
},
|
||||
],
|
||||
freshness: { stateMaxQuery: '2026-10-27 13:04:00', coversDeadline: true, stale: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function config(overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test',
|
||||
webhookSecret: '',
|
||||
noonSendTime: '13:05',
|
||||
afternoonSendTime: '18:05',
|
||||
maxDetailRows: 30,
|
||||
atAll: false,
|
||||
pageUrl: 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造北京时间某个时刻(本机是东八区,直接 new Date(本地字段) 即可) */
|
||||
function bj(year, month, day, hour, minute) {
|
||||
return new Date(year, month - 1, day, hour, minute, 0, 0);
|
||||
}
|
||||
|
||||
// ── ① 两个轮次各自的推送时刻 ──────────────────────────────────────────────
|
||||
test('两个轮次按各自配置的时刻命中', () => {
|
||||
const cfg = config();
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON']);
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 18, 5)), ['AFTERNOON']);
|
||||
// 非命中时刻一律不推(免得每分钟都轰炸一次)
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 6)), []);
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 12, 59)), []);
|
||||
});
|
||||
|
||||
test('两个时刻配成同一个 HH:mm 时,两轮都要推(不能只推第一轮)', () => {
|
||||
const cfg = config({ noonSendTime: '13:05', afternoonSendTime: '13:05' });
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON', 'AFTERNOON']);
|
||||
});
|
||||
|
||||
test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补', () => {
|
||||
const cfg = config();
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: true }), ['NOON']);
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 40), { catchUp: true }), []);
|
||||
// 关掉补推时,错过就错过
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: false }), []);
|
||||
});
|
||||
|
||||
// ── ② 正文内容 ────────────────────────────────────────────────────────────
|
||||
test('正文包含轮次、超时单数、涉及仓数、最久天数', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /【仓储中心发货时效】13:00 轮/);
|
||||
assert.match(built.text, /核验 07:00 前审核/);
|
||||
assert.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/);
|
||||
assert.equal(built.rowCount, 3);
|
||||
});
|
||||
|
||||
test('按仓块只列超时 > 0 的仓,并按单数降序', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /按仓(超时单):/);
|
||||
assert.match(built.text, /1\. 崔桥工厂仓 2 单(最久 4 天)/);
|
||||
assert.match(built.text, /2\. 横林二仓 1 单(最久 1 天)/);
|
||||
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
|
||||
});
|
||||
|
||||
test('明细一行 = 仓|内部单号|线上单号|快递|审核 时间|已等', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分/);
|
||||
// 无线上单号 → 「—」;无快递名 → 「—」;无创建时间 → 「无创建时间」;都不能出现 undefined
|
||||
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|审核 —|已等 2 小时/);
|
||||
assert.doesNotMatch(built.text, /undefined|NaN/);
|
||||
});
|
||||
|
||||
test('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看是哪家快递)', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
|
||||
assert.ok(line, '明细第 2 行不见了');
|
||||
const parts = line.replace(/^\d+\.\s*/, '').split('|');
|
||||
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']);
|
||||
});
|
||||
|
||||
test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => {
|
||||
// 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|审核 10-26 09:05|已等 1 天 23 小时/);
|
||||
// 同一天的就只给 HH:mm,别啰嗦
|
||||
assert.match(built.text, /审核 06:32/);
|
||||
assert.doesNotMatch(built.text, /审核 10-27 06:32/);
|
||||
// 直接测函数本身的跨天判定
|
||||
assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32');
|
||||
assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13');
|
||||
assert.equal(job.auditWhen('', '2026-10-27'), '');
|
||||
});
|
||||
|
||||
test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /另有 5 单不计考核/);
|
||||
assert.match(built.text, /已剔除 2 单不考核快递/);
|
||||
assert.doesNotMatch(built.text, /⚠️/);
|
||||
|
||||
const stale = job.buildNotifyText(sampleData({
|
||||
freshness: { stateMaxQuery: '2026-10-27 12:31:00', coversDeadline: false, stale: true },
|
||||
}), { maxDetailRows: 30 });
|
||||
assert.match(stale.text, /⚠️ 数据快照只到 12:31/);
|
||||
assert.match(stale.text, /尚未覆盖本轮核验时刻 13:00/);
|
||||
assert.match(stale.text, /超时单量偏大/);
|
||||
});
|
||||
|
||||
// ── ③ 0 单也要推 ──────────────────────────────────────────────────────────
|
||||
test('本轮 0 单超时:发「全部按时发出」,不带空明细块', () => {
|
||||
const built = job.buildNotifyText(sampleData({
|
||||
summary: { overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, heldCount: 0, voidCount: 3, carrierSkipped: 0 },
|
||||
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 0, oldestDays: 0 }],
|
||||
orders: [],
|
||||
}), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /超时未发货 0 单,涉及 0 个仓/);
|
||||
assert.match(built.text, /本轮没有超时未发货的订单,全部按时发出/);
|
||||
assert.doesNotMatch(built.text, /明细前/);
|
||||
assert.doesNotMatch(built.text, /查看全部/);
|
||||
assert.equal(built.rowCount, 0);
|
||||
assert.equal(built.listedCount, 0);
|
||||
});
|
||||
|
||||
// ── ④ 明细额度:按仓保底 + 超长精简 ───────────────────────────────────────
|
||||
test('明细额度按仓保底:一个仓吃不满额度,其它仓至少 1 条', () => {
|
||||
const many = [];
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
many.push({ oId: `A${i}`, soId: '', centerName: '崔桥工厂仓', warehouseName: '简尚-崔桥工厂仓', overdueMinutes: 600 + i });
|
||||
}
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
many.push({ oId: `B${i}`, soId: '', centerName: '横林二仓', warehouseName: '简尚-横林二仓', overdueMinutes: 300 });
|
||||
}
|
||||
const picked = job.pickDetailRows(many, 6);
|
||||
assert.equal(picked.length, 6);
|
||||
// 每仓保底 1 条,剩余额度按各仓条数比例分(40:2 ⇒ 5:1)
|
||||
assert.equal(picked.filter((row) => row.centerName === '横林二仓').length, 1);
|
||||
assert.equal(picked.filter((row) => row.centerName === '崔桥工厂仓').length, 5);
|
||||
// 组间按仓聚集:条数多的仓排前面,所以首行是崔桥
|
||||
assert.equal(picked[0].centerName, '崔桥工厂仓');
|
||||
});
|
||||
|
||||
test('明细条数 0 = 只发汇总不带明细', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
|
||||
assert.equal(built.listedCount, 0);
|
||||
assert.match(built.text, /明细共 3 条,消息里不放/);
|
||||
});
|
||||
|
||||
test('超长时从明细末尾往前砍,砍到能发为止', () => {
|
||||
// 故意把线上单号拉长(真实数据没见过,但业务改过列宽/加字段后有可能),
|
||||
// 保证 50 条明细 + 分仓块必然超过 20000 字节上限
|
||||
const rows = [];
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
rows.push({
|
||||
oId: `98${String(i).padStart(6, '0')}`,
|
||||
soId: `SO-2026-10-27-${String(i).padStart(6, '0')}-${'X'.repeat(360)}`,
|
||||
centerName: '崔桥工厂仓',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
auditTime: '2026-10-27 06:32:11',
|
||||
overdueMinutes: 400 + i,
|
||||
});
|
||||
}
|
||||
const built = job.buildNotifyText(sampleData({
|
||||
summary: { overdueUnshipped: 300, overdueCenterCount: 1, oldestDays: 4, heldCount: 0, voidCount: 0, carrierSkipped: 0 },
|
||||
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 300, oldestDays: 4 }],
|
||||
orders: rows,
|
||||
}), { maxDetailRows: 50 });
|
||||
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, `bytes=${built.bytes} 超上限`);
|
||||
assert.equal(built.truncated, true);
|
||||
assert.ok(built.listedCount < 50);
|
||||
assert.match(built.text, /其余 \d+ 条见页面/);
|
||||
});
|
||||
|
||||
// ── ⑤ 去重键 ──────────────────────────────────────────────────────────────
|
||||
test('去重键是「日期|轮次」,两轮互不覆盖', () => {
|
||||
const noon = settings.buildPushSlot('2026-10-27', 'NOON');
|
||||
const afternoon = settings.buildPushSlot('2026-10-27', 'AFTERNOON');
|
||||
assert.equal(noon, '2026-10-27|NOON');
|
||||
assert.equal(afternoon, '2026-10-27|AFTERNOON');
|
||||
assert.notEqual(noon, afternoon);
|
||||
// 小写也归一
|
||||
assert.equal(settings.buildPushSlot('2026-10-27', 'noon'), '2026-10-27|NOON');
|
||||
assert.equal(settings.buildPushSlot('', 'NOON'), '');
|
||||
});
|
||||
|
||||
test('推送时刻归一化:非法值退回默认,不抛错', () => {
|
||||
assert.equal(settings.normalizeSendTime('13:05'), '13:05');
|
||||
assert.equal(settings.normalizeSendTime('8:5'), '13:05');
|
||||
assert.equal(settings.normalizeSendTime('25:00'), '13:05');
|
||||
assert.equal(settings.normalizeSendTime(''), '13:05');
|
||||
assert.equal(settings.normalizeSendTime('', '18:05'), '18:05');
|
||||
assert.equal(settings.DEFAULT_SEND_TIME_NOON, '13:05');
|
||||
assert.equal(settings.DEFAULT_SEND_TIME_AFTERNOON, '18:05');
|
||||
});
|
||||
|
||||
test('超长文案格式化:分钟 / 小时 / 跨天', () => {
|
||||
assert.equal(job.formatOverdue(30), '30 分钟');
|
||||
assert.equal(job.formatOverdue(120), '2 小时');
|
||||
assert.equal(job.formatOverdue(388), '6 小时 28 分');
|
||||
assert.equal(job.formatOverdue(1500), '1 天 1 小时');
|
||||
assert.equal(job.formatOverdue(0), '—');
|
||||
assert.equal(job.formatOverdue(null), '—');
|
||||
});
|
||||
|
||||
test('页面链接带上轮次参数,业务点进去看的就是这一轮', () => {
|
||||
const link = job.buildPageLink('https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness', 'AFTERNOON');
|
||||
assert.equal(link, 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness?round=AFTERNOON');
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /\?round=NOON/);
|
||||
});
|
||||
|
||||
test('轮次元信息:13:00 轮核验 07:00、18:00 轮核验 16:00', () => {
|
||||
const noon = job.resolveRoundMeta('NOON', bj(2026, 10, 27, 13, 5));
|
||||
const afternoon = job.resolveRoundMeta('AFTERNOON', bj(2026, 10, 27, 18, 5));
|
||||
assert.equal(noon.deadline, '2026-10-27 13:00:00');
|
||||
assert.equal(noon.windowEnd, '2026-10-27 07:00:00');
|
||||
assert.equal(afternoon.deadline, '2026-10-27 18:00:00');
|
||||
assert.equal(afternoon.windowEnd, '2026-10-27 16:00:00');
|
||||
assert.equal(job.resolveRoundMeta('UNKNOWN', bj(2026, 10, 27, 13, 5)), null);
|
||||
});
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
);
|
||||
@ -1,59 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
// 转运异常要扫一遍「昨日发货」的物流宽表(rpt 下全是视图),冷启动实测 20 秒左右,
|
||||
// 超时放大到 3 分钟,避免前端先断开、后端还在跑。
|
||||
const TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
// 仓储中心物流转运异常:12 个发货仓的异常单量 + 明细
|
||||
// params.date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天)
|
||||
// params.limit 明细条数上限
|
||||
// params.force 1 = 绕过 5 分钟结果缓存
|
||||
export const getWarehouseLogisticsExceptions = (params = {}) => request.get(
|
||||
'/api/warehouse-logistics-exception/list',
|
||||
{
|
||||
params,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
}
|
||||
);
|
||||
|
||||
// 口径元数据(12 个仓、异常类型、回看天数),不打库,秒回
|
||||
export const getWarehouseLogisticsExceptionMeta = () => request.get(
|
||||
'/api/warehouse-logistics-exception/meta',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// 导出用:同一套口径、不分页(明细上限放到 5000),比列表更慢,超时再放大
|
||||
export const getWarehouseLogisticsExceptionExportRows = (params = {}) => request.get(
|
||||
'/api/warehouse-logistics-exception/export',
|
||||
{
|
||||
params,
|
||||
timeout: 5 * 60 * 1000,
|
||||
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 }
|
||||
);
|
||||
@ -4,7 +4,7 @@ import request from './request';
|
||||
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
||||
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
// 仓储中心发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
||||
// params.limit 明细条数上限
|
||||
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
||||
@ -21,40 +21,3 @@ export const getWarehouseShipTimelinessRounds = () => request.get(
|
||||
'/api/warehouse-ship-timeliness/rounds',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// 「超时未发」明细导出:不分页、不截断,返回本轮考核口径内的全部行。
|
||||
// 与 /list 共用后端同一份缓存,所以页面刚刷过时这里是秒回;
|
||||
// 冷启动(缓存已过期)仍要全量扫视图,超时同样放大到 3 分钟。
|
||||
export const getWarehouseShipTimelinessExportRows = (params = {}) => request.get(
|
||||
'/api/warehouse-ship-timeliness/export',
|
||||
{
|
||||
params,
|
||||
timeout: TIMELINESS_TIMEOUT_MS,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
}
|
||||
);
|
||||
|
||||
// ── 两轮核验完成后推文本到钉钉群(2026-10-27 新增)────────────────────────
|
||||
// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选)
|
||||
// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview)
|
||||
export const getShipTimelinessNotifySettings = () => request.get(
|
||||
'/api/warehouse-ship-timeliness/notify/settings',
|
||||
{ timeout: 30000, headers: { 'Cache-Control': 'no-cache' } }
|
||||
);
|
||||
|
||||
// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填
|
||||
export const saveShipTimelinessNotifySettings = (payload = {}) => request.put(
|
||||
'/api/warehouse-ship-timeliness/notify/settings',
|
||||
payload,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// 立即推送 / 预览正文 / 测试通道
|
||||
// dryRun=true 只组装消息不发送(返回正文全文)
|
||||
// probe=true 只发一句通道测试,不查业务数据
|
||||
// 冷启动要全量扫订单状态视图(生产实测数十秒到几分钟),超时给足
|
||||
export const runShipTimelinessNotify = (payload = {}) => request.post(
|
||||
'/api/warehouse-ship-timeliness/notify/run',
|
||||
payload,
|
||||
{ timeout: 10 * 60 * 1000 }
|
||||
);
|
||||
|
||||
@ -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: [
|
||||
@ -37,8 +36,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
|
||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||
{
|
||||
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||
children: [
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -23,7 +23,6 @@ import {
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
SunOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Dropdown, Switch } from 'antd';
|
||||
import { verifyToken } from '../api/auth';
|
||||
@ -71,7 +70,6 @@ const SIDEBAR_ICON_MAP = {
|
||||
shopping: ShoppingOutlined,
|
||||
table: TableOutlined,
|
||||
team: TeamOutlined,
|
||||
warning: WarningOutlined,
|
||||
};
|
||||
|
||||
const SIDEBAR_ROUTE_MAP = {
|
||||
@ -126,19 +124,9 @@ const SIDEBAR_ROUTE_MAP = {
|
||||
activePath: '/planning/operation/warehouse-ship-timeliness',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'warehouse-logistics-exception': {
|
||||
path: '/planning/operation/warehouse-logistics-exception',
|
||||
activePath: '/planning/operation/warehouse-logistics-exception',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'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' },
|
||||
|
||||
@ -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>待发订单量 > 拣货位库存</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 —— 即「待发订单量 > 拣货位库存」、需要从存储位补到拣货位的这部分
|
||||
(含拣货位为 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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,982 +0,0 @@
|
||||
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,
|
||||
} from 'antd';
|
||||
import { BellOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import {
|
||||
getWarehouseLogisticsExceptions,
|
||||
getWarehouseLogisticsExceptionExportRows,
|
||||
getWarehouseLogisticsExceptionNotifySettings,
|
||||
saveWarehouseLogisticsExceptionNotifySettings,
|
||||
runWarehouseLogisticsExceptionNotify,
|
||||
} from '../../api/warehouseLogisticsException';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// 核验点每天只有 09:00 一个,10 分钟刷一次足够;接口冷启动要 20 秒,别跟着秒级轮询跑
|
||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||
// 回看窗口(与后端 LOOKBACK_DAYS 对齐:今天是第 1 天,往前 29 天)
|
||||
const LOOKBACK_DAYS = 30;
|
||||
|
||||
const number = (value, digits = 0) => (
|
||||
value === null || value === undefined || value === ''
|
||||
? '0'
|
||||
: Number(value).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
);
|
||||
|
||||
// 小时数 → 「32 小时」/「1 天 8 小时」
|
||||
const formatHours = (hours) => {
|
||||
const h = Number(hours);
|
||||
if (!Number.isFinite(h)) return '—';
|
||||
if (h < 0) return '核验后';
|
||||
if (h < 1) return '不到 1 小时';
|
||||
if (h < 24) return `${Math.round(h)} 小时`;
|
||||
const d = Math.floor(h / 24);
|
||||
const rest = Math.round(h % 24);
|
||||
return rest ? `${d} 天 ${rest} 小时` : `${d} 天`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 明细过滤(仓 + 异常类型)。
|
||||
* ⚠️ 页面表格和「导出」必须共用这一个函数 —— 否则导出的行和页面看到的对不上。
|
||||
*/
|
||||
const filterDetailOrders = (orders, { centerKey = '', typeKey = '' } = {}) => (
|
||||
(Array.isArray(orders) ? orders : []).filter((o) => (
|
||||
(!centerKey || o.centerKey === centerKey)
|
||||
&& (!typeKey || o.exceptionType === typeKey)
|
||||
))
|
||||
);
|
||||
|
||||
// 导出列:标题必须是纯文本(页面列用了 Tooltip,直接复用会导出成 [object Object])
|
||||
const EXPORT_COLUMNS = [
|
||||
{ title: '发货仓', dataIndex: 'warehouseName' },
|
||||
{ title: '内部单号', dataIndex: 'oId' },
|
||||
{ title: '线上订单号', dataIndex: 'soId' },
|
||||
{ title: '店铺', dataIndex: 'shopName' },
|
||||
{ title: '快递公司', dataIndex: 'carrier' },
|
||||
{ title: '运单号', dataIndex: 'trackingNo', exportValue: (v) => v || '无运单号' },
|
||||
{ title: '物流状态', dataIndex: 'logisticStatus', exportValue: (v) => v || '无状态' },
|
||||
{ title: '异常类型', dataIndex: 'exceptionLabel' },
|
||||
{ title: '异常说明', dataIndex: 'exceptionDesc' },
|
||||
{ title: '发货时间', dataIndex: 'sendTime' },
|
||||
{ title: '最后物流时间', dataIndex: 'lastLogisticTime', exportValue: (v) => v || '无轨迹' },
|
||||
{
|
||||
title: '发货后待处理(小时)',
|
||||
dataIndex: 'pendingHours',
|
||||
exportValue: (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v) * 10) / 10 : ''),
|
||||
},
|
||||
{ title: '最后物流描述', dataIndex: 'lastLogisticInfo' },
|
||||
];
|
||||
|
||||
// 导出菜单:key = `<format>`;sheet/文件名里带核验日,业务归档好找
|
||||
const EXPORT_MENU_ITEMS = [
|
||||
{ key: 'xlsx', label: '导出 Excel(.xlsx)' },
|
||||
{ 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: '发货仓',
|
||||
dataIndex: 'warehouseName',
|
||||
width: 190,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{ title: '内部单号', dataIndex: 'oId', width: 96 },
|
||||
{
|
||||
title: '线上订单号',
|
||||
dataIndex: 'soId',
|
||||
width: 178,
|
||||
ellipsis: true,
|
||||
render: (v) => (v ? <span className="wle-detail__soid">{v}</span> : '—'),
|
||||
},
|
||||
{ title: '店铺', dataIndex: 'shopName', width: 150, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '快递公司', dataIndex: 'carrier', width: 130, ellipsis: true, render: (v) => v || '—' },
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="聚水潭回传的运单号。空的说明仓库已推单但快递没建单">
|
||||
<span>运单号</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'trackingNo',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (v) => (v ? v : <Tag bordered={false} color="red">无运单号</Tag>),
|
||||
},
|
||||
{
|
||||
title: '物流状态',
|
||||
dataIndex: 'logisticStatus',
|
||||
width: 100,
|
||||
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : <Tag bordered={false} color="red">无状态</Tag>),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="判定到核验时刻(当天 09:00)物流卡在哪一步;点类型图例可只看某一类">
|
||||
<span>异常类型</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'exceptionLabel',
|
||||
width: 116,
|
||||
render: (v, row) => (
|
||||
<Tooltip title={row?.exceptionDesc || v}>
|
||||
<Tag bordered={false} color={row?.tone || 'default'}>{v}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{ title: '发货时间', dataIndex: 'sendTime', width: 152, ellipsis: true },
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="这条运单最后一条物流轨迹的时间。空白说明快递还没揽收、没有任何轨迹">
|
||||
<span>最后物流时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'lastLogisticTime',
|
||||
width: 152,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="从发货时间到核验时刻(核验日 09:00)经过多久 —— 仓库看到「发了多久物流还没动静」">
|
||||
<span>发货后待处理</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'pendingHours',
|
||||
width: 118,
|
||||
render: (v) => <span className="wle-detail__hours">{formatHours(v)}</span>,
|
||||
},
|
||||
{
|
||||
title: '最后物流描述',
|
||||
dataIndex: 'lastLogisticInfo',
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (v) => (v ? (
|
||||
<Tooltip title={v}>
|
||||
<span>{v}</span>
|
||||
</Tooltip>
|
||||
) : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常(独立页面)
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 业务口径(yanhui 2026-10-21,二次收敛):
|
||||
* · 12 个发货仓,子仓并入主仓一起算;
|
||||
* · **每天 09:00 核验前一日已发货订单的物流记录** —— 到 9 点还卡在
|
||||
* 「未揽收」或「已揽收但没有下一步中转」就是转运异常,按仓归口;
|
||||
* · 运输中 / 派送中 / 已签收 = 正常,不进列表;不再按「24h 无新轨迹」判停滞;
|
||||
* · 刷单、信誉单、已取消/作废/拦截、指定承运商不参与考核;
|
||||
* · 可回看近 30 天,但历史批次是按**当前**物流快照重算的(页面会提示)。
|
||||
*
|
||||
* 数据来自 /api/warehouse-logistics-exception/list(service 层见
|
||||
* backend/src/services/warehouseLogisticsExceptionService.js)。
|
||||
*/
|
||||
function WarehouseLogisticsException() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// 选中的仓:为空时展示全部仓
|
||||
const [activeCenter, setActiveCenter] = useState('');
|
||||
// 异常类型筛选:为空 = 全部
|
||||
const [activeType, setActiveType] = useState('');
|
||||
// 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
||||
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);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getWarehouseLogisticsExceptions({
|
||||
limit: 1000,
|
||||
force: force ? 1 : 0,
|
||||
date: verifyDate ? verifyDate.format('YYYY-MM-DD') : undefined,
|
||||
});
|
||||
if (!result || result.success === false) {
|
||||
setError(result?.error || '加载物流转运异常失败');
|
||||
return;
|
||||
}
|
||||
setData(result.data || null);
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || loadError?.message || '加载物流转运异常失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [verifyDate]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(() => load(), AUTO_REFRESH_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const centers = useMemo(() => (Array.isArray(data?.centers) ? data.centers : []), [data]);
|
||||
const allOrders = useMemo(() => (Array.isArray(data?.orders) ? data.orders : []), [data]);
|
||||
const types = useMemo(() => (Array.isArray(data?.types) ? data.types : []), [data]);
|
||||
const summary = data?.summary || {};
|
||||
const batch = data?.batch || null;
|
||||
const freshness = data?.freshness || null;
|
||||
// 剔除口径 + 本批实际剔除量(后端给什么就展示什么,页面不硬编码名单)
|
||||
const exclusions = data?.exclusions || null;
|
||||
const notStarted = Boolean(data?.notStarted);
|
||||
|
||||
const activeCenterName = useMemo(() => {
|
||||
if (!activeCenter) return '';
|
||||
const hit = centers.find((c) => c.key === activeCenter);
|
||||
return hit ? hit.name : activeCenter;
|
||||
}, [activeCenter, centers]);
|
||||
|
||||
const detailOrders = useMemo(
|
||||
() => filterDetailOrders(allOrders, { centerKey: activeCenter, typeKey: activeType }),
|
||||
[activeCenter, activeType, allOrders]
|
||||
);
|
||||
|
||||
const disabledDate = (current) => (
|
||||
current && (current > dayjs().endOf('day')
|
||||
|| current < dayjs().startOf('day').subtract(LOOKBACK_DAYS - 1, 'day'))
|
||||
);
|
||||
|
||||
/**
|
||||
* 导出异常明细。
|
||||
* 口径要点:**按当前核验日 + 当前筛选(仓 / 异常类型)导出全部**,不是只导当前屏。
|
||||
* 空表头 / 空筛选时导出的是「本批全部异常」,文件名叫「仓储物流转运异常_<核验日>」。
|
||||
*/
|
||||
const handleExport = async (format) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const result = await getWarehouseLogisticsExceptionExportRows({
|
||||
date: verifyDate ? verifyDate.format('YYYY-MM-DD') : undefined,
|
||||
});
|
||||
if (!result || result.success === false) {
|
||||
message.error(result?.error || '导出失败');
|
||||
return;
|
||||
}
|
||||
const payload = result.data || {};
|
||||
const rows = filterDetailOrders(payload.rows, {
|
||||
centerKey: activeCenter,
|
||||
typeKey: activeType,
|
||||
});
|
||||
if (!rows.length) {
|
||||
message.warning('当前筛选条件下没有可导出的异常明细');
|
||||
return;
|
||||
}
|
||||
const typeLabel = activeType ? (types.find((t) => t.key === activeType)?.label || activeType) : '';
|
||||
const scope = [activeCenterName, typeLabel].filter(Boolean).join('_');
|
||||
const verifyDateText = payload.batch?.verifyDate || dayjs().format('YYYY-MM-DD');
|
||||
const fileName = `仓储物流转运异常_${verifyDateText}${scope ? `_${scope}` : ''}`;
|
||||
await exportTableFile(format, fileName, EXPORT_COLUMNS, rows, '转运异常明细');
|
||||
const truncatedHint = payload.truncated
|
||||
? `;明细超过上限 ${payload.detailLimit} 行,已截断`
|
||||
: '';
|
||||
message.success(`已导出 ${rows.length} 行(${String(format).toUpperCase()})${truncatedHint}`);
|
||||
} catch (exportError) {
|
||||
message.error(exportError?.response?.data?.error || exportError?.message || '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 异常通知:每天定点把异常情况以文本推到钉钉群 ──────────────────────────
|
||||
|
||||
// 后端只回加签密钥掩码,所以表单里的 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">
|
||||
<Title level={3}>仓储中心物流转运异常</Title>
|
||||
<Text type="secondary" className="wle-note">
|
||||
<b>每天 09:00 核验 12 个发货仓「前一日已发货」订单的物流记录</b>,只看异常,正常签收的不列。
|
||||
核验批次 = <b>核验日 09:00</b> + <b>前一天发货的单</b>。
|
||||
到核验时刻物流还卡在<b>揽收前</b>就算转运异常,分五类:
|
||||
<b>未揽收</b>(发了货快递没来揽)、<b>已揽收未中转</b>(揽了但没下一步中转,卡在网点)、
|
||||
<b>无物流单</b>(连运单号都没建)、<b>快递报异常</b>(如寄件人取消寄件、异常签收)、
|
||||
<b>退回</b>(拒收 / 换单退回)。
|
||||
<b>运输中、派送中、已签收 = 正常,不进列表</b> —— 有「运输」字样之后的中转记录不回传,不作考核。
|
||||
一单多包裹按包裹列明细、按内部单号统计,异常率不会被多包裹单放大。
|
||||
<b>不参与考核</b>:顺丰系全部(唯品顺丰 / 顺丰干配 / 顺丰大件各账号)、武进安能、中铁、壹米滴答、
|
||||
京喜京东(快递·快运)、自提、线下、商家配送
|
||||
(轨迹不回传或本身无快递轨迹);
|
||||
<b>睢宁中通</b> 只在睢宁仓考核,其它仓异地打单的剔出;
|
||||
<b>已取消 / 作废 / 拦截</b>的订单(单据已终止)、<b>刷单 / 信誉单</b>(本身不是真实发货)。
|
||||
<b>历史批次是按当前物流快照回算的</b> —— 昨天是异常、今天已签收的单会显示成正常,
|
||||
要留档结论请以当天 09:00 看到的为准。
|
||||
</Text>
|
||||
</header>
|
||||
|
||||
<Card className="wle-status" bordered={false}>
|
||||
<div className="wle-status__row">
|
||||
<div className="wle-status__batch">
|
||||
{notStarted ? (
|
||||
<>
|
||||
<span className="wle-status__badge wle-status__badge--idle">未到核验时间</span>
|
||||
<span className="wle-status__label">
|
||||
{String(batch?.deadline || '').slice(5, 16)} 才开始核验
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="wle-status__badge">{batch?.label || '—'}</span>
|
||||
<span className="wle-status__label">
|
||||
核验 {batch?.shipDate || '—'} 已发货订单的物流记录
|
||||
</span>
|
||||
{batch?.lookback ? (
|
||||
<span className="wle-status__window">历史批次 · 按当前快照回算</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Space size={8} wrap>
|
||||
<DatePicker
|
||||
value={verifyDate || (batch?.verifyDate ? dayjs(batch.verifyDate) : null)}
|
||||
disabledDate={disabledDate}
|
||||
onChange={(value) => setVerifyDate(value ? value.startOf('day') : null)}
|
||||
style={{ width: 148 }}
|
||||
placeholder="核验日"
|
||||
/>
|
||||
{data?.generatedAt ? (
|
||||
<Text type="secondary" className="wle-status__time">
|
||||
数据更新于 {String(data.generatedAt).slice(11, 19)}
|
||||
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s)` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
disabled={exporting || notStarted}
|
||||
menu={{ items: EXPORT_MENU_ITEMS, onClick: ({ key }) => handleExport(key) }}
|
||||
>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting}>导出</Button>
|
||||
</Dropdown>
|
||||
<Button icon={<BellOutlined />} onClick={openNotifySettings}>异常通知</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="wle-alert"
|
||||
message={error}
|
||||
action={<Button size="small" onClick={() => load(true)}>重试</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{freshness && (freshness.coversDeadline === false || freshness.stale) ? (
|
||||
<Alert
|
||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
showIcon
|
||||
className="wle-alert"
|
||||
message={freshness.coversDeadline === false
|
||||
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
||||
: '物流数据可能滞后'}
|
||||
description={freshness.hint}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="wle-stats">
|
||||
<Card bordered={false} className="wle-stat">
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="核验日的前一天,12 个发货仓发出的订单总数(按内部单号去重)">
|
||||
<span>前一日发货</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
value={notStarted ? 0 : (summary.shippedOrders ?? 0)}
|
||||
suffix="单"
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹
|
||||
</Text>
|
||||
</Card>
|
||||
<Card bordered={false} className="wle-stat">
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="到 09:00 核验时刻物流仍不正常的订单(按内部单号去重)">
|
||||
<span>转运异常</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
value={notStarted ? 0 : (summary.exceptionOrders ?? 0)}
|
||||
suffix="单"
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
valueStyle={{ color: (summary.exceptionOrders || 0) > 0 ? '#cf1322' : undefined }}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
涉及 {notStarted ? '—' : number(summary.exceptionPackages)} 个包裹
|
||||
</Text>
|
||||
</Card>
|
||||
<Card bordered={false} className="wle-stat">
|
||||
<Statistic
|
||||
title="异常率"
|
||||
value={notStarted ? 0 : (summary.exceptionRate ?? 0)}
|
||||
suffix="%"
|
||||
formatter={(v) => (notStarted ? '—' : number(v, 1))}
|
||||
valueStyle={{ color: (summary.exceptionRate || 0) >= 10 ? '#d46b08' : undefined }}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
异常单 ÷ 前一日发货单
|
||||
</Text>
|
||||
</Card>
|
||||
<Card bordered={false} className="wle-stat">
|
||||
<Statistic
|
||||
title="涉及仓库"
|
||||
value={notStarted ? 0 : (summary.centerCount ?? 0)}
|
||||
suffix={`/ ${centers.length || 12}`}
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
有转运异常的仓
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{types.length ? (
|
||||
<div className="wle-types">
|
||||
{types.map((t) => {
|
||||
const count = summary.byType?.[t.key] || 0;
|
||||
const isActive = activeType === t.key;
|
||||
return (
|
||||
<Tooltip key={t.key} title={t.desc || t.label}>
|
||||
<button
|
||||
type="button"
|
||||
className={`wle-type${isActive ? ' is-active' : ''}${count ? '' : ' is-empty'}`}
|
||||
onClick={() => setActiveType(isActive ? '' : t.key)}
|
||||
>
|
||||
<Tag bordered={false} color={t.tone}>{t.label}</Tag>
|
||||
<span className="wle-type__count">{notStarted ? '—' : count}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{activeType ? (
|
||||
<button type="button" className="wle-type__clear" onClick={() => setActiveType('')}>
|
||||
取消筛选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="wle-body">
|
||||
<Card
|
||||
bordered={false}
|
||||
className="wle-centers"
|
||||
title="发货仓"
|
||||
extra={<Text type="secondary" className="wle-centers__hint">点仓库看明细</Text>}
|
||||
>
|
||||
<Spin spinning={loading && !data} size="small">
|
||||
<ul className="wle-centers__list">
|
||||
{centers.map((c) => {
|
||||
const isActive = activeCenter === c.key;
|
||||
return (
|
||||
<li key={c.key}>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={`${c.name}:前一日发货 ${c.shippedOrders} 单,转运异常 ${c.exceptionOrders} 单`
|
||||
+ `${c.shippedOrders ? `(异常率 ${c.exceptionRate}%)` : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'wle-center',
|
||||
isActive ? 'is-active' : '',
|
||||
c.exceptionOrders ? '' : 'is-clean',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => setActiveCenter(isActive ? '' : c.key)}
|
||||
>
|
||||
<span className="wle-center__name">{c.name}</span>
|
||||
<span className={`wle-center__badge${c.exceptionOrders > 0 ? ' is-warn' : ''}`}>
|
||||
{notStarted ? '—' : (c.exceptionOrders > 0 ? c.exceptionOrders : '—')}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
className="wle-detail"
|
||||
title={(
|
||||
<span>
|
||||
转运异常明细
|
||||
<span className="wle-detail__scope">
|
||||
{[activeCenterName, activeType ? types.find((t) => t.key === activeType)?.label : '']
|
||||
.filter(Boolean).join(' · ') || '全部仓库'}
|
||||
{' · '}
|
||||
{detailOrders.length} 条
|
||||
{data?.truncated && allOrders.length >= (data?.detailLimit || 0)
|
||||
? `(已截断,仅显示前 ${data.detailLimit} 条)`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
extra={(activeCenter || activeType) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="wle-detail__clear"
|
||||
onClick={() => { setActiveCenter(''); setActiveType(''); }}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
) : null}
|
||||
>
|
||||
<Spin spinning={loading && !data}>
|
||||
{notStarted ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={`未到核验时间,${String(batch?.deadline || '').slice(5, 16)} 开始核验`}
|
||||
/>
|
||||
) : detailOrders.length ? (
|
||||
<Table
|
||||
rowKey={(r) => `${r.oId}-${r.trackingNo || r.sendTime}-${r.exceptionType}`}
|
||||
size="small"
|
||||
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 条` }}
|
||||
dataSource={detailOrders}
|
||||
columns={DETAIL_COLUMNS}
|
||||
scroll={{ x: 1900 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={activeCenter || activeType
|
||||
? '这个筛选条件下没有转运异常'
|
||||
: `前一日发货的订单物流都正常(或还没到核验时刻)`}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Text type="secondary" className="wle-foot">
|
||||
说明:每天 <b>09:00</b> 核验<b>前一日已发货</b>订单的物流记录,只看两个卡点 ——
|
||||
<b>未揽收</b>(快递没来揽)和<b>已揽收未中转</b>(揽了但没有下一步中转记录,卡在网点),
|
||||
外加 <b>无物流单 / 快递报异常 / 退回</b>。
|
||||
<b>运输中、派送中、已签收一律正常</b>:「运输」字样之后的中转记录本来就不回传聚水潭,不作考核。
|
||||
子仓并入主仓统计;<b>京仓-横林二仓京东云仓</b> 与各代发仓不在 12 个发货仓范围内。
|
||||
<br />
|
||||
<b>不参与考核</b>:
|
||||
{exclusions?.carriers?.length ? (
|
||||
<>承运商 <b>{exclusions.carriers.join(' / ')}</b>(这些专线轨迹不回传聚水潭或本身无快递轨迹);</>
|
||||
) : null}
|
||||
{exclusions?.conditionalCarriers?.length ? (
|
||||
<>
|
||||
{exclusions.conditionalCarriers.map((r) => (
|
||||
<span key={r.keyword}>
|
||||
<b>{r.keyword}</b> 只在 <b>{(r.keepCenters || []).join(' / ')}</b> 考核,
|
||||
其它仓异地用同一账号打单的剔出({r.reason});
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<b>已取消 / 作废 / 拦截</b>的订单
|
||||
{exclusions?.orderLabelKeywords?.length
|
||||
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
|
||||
: null}
|
||||
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流;
|
||||
<b>现场拦截</b>(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常;
|
||||
<b>转仓代发</b>(标签如「转x00005」,货由目标仓发出)—— 不算本仓异常,也不算本仓发货量。
|
||||
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
||||
|| exclusions.nonRealOrders > 0 || exclusions.transferOrders > 0) ? (
|
||||
<>
|
||||
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
||||
+ (exclusions.nonRealOrders || 0) + (exclusions.transferOrders || 0)}</b> 单
|
||||
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
||||
刷单/信誉单 {exclusions.nonRealOrders || 0} 单、转仓代发 {exclusions.transferOrders || 0} 单),
|
||||
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
|
||||
<>
|
||||
其中承运商剔除:<b>
|
||||
{Object.entries(exclusions.carrierReasons)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${k} ${v} 单`)
|
||||
.join(' / ')}
|
||||
</b>;
|
||||
</>
|
||||
) : null}
|
||||
剔除的单<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default WarehouseLogisticsException;
|
||||
@ -1,345 +0,0 @@
|
||||
.wle-page {
|
||||
padding: 16px 20px 24px;
|
||||
}
|
||||
|
||||
.wle-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wle-header .ant-typography {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 口径说明:页面顶部是深色渐变,直接放浅色小字看不清,给一块浅底 */
|
||||
.wle-note {
|
||||
display: block;
|
||||
max-width: 1180px;
|
||||
margin-top: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--sycm-bg-container);
|
||||
border: 1px solid var(--sycm-border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ============ 批次状态条 ============ */
|
||||
.wle-status {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wle-status__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wle-status__batch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wle-status__badge {
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #0958d9;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.wle-status__badge--idle {
|
||||
background: rgba(136, 135, 128, 0.14);
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.wle-status__label {
|
||||
font-size: 13px;
|
||||
color: var(--sycm-text-primary);
|
||||
}
|
||||
|
||||
.wle-status__window,
|
||||
.wle-status__time {
|
||||
font-size: 12px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.wle-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ============ 统计卡 ============ */
|
||||
.wle-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.wle-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.wle-stat {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wle-stat__sub {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============ 异常类型筛选 ============ */
|
||||
.wle-types {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wle-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px 2px 2px;
|
||||
border: 1px solid var(--sycm-border-color);
|
||||
border-radius: 16px;
|
||||
background: var(--sycm-bg-container);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.wle-type:hover {
|
||||
border-color: rgba(22, 119, 255, 0.45);
|
||||
}
|
||||
|
||||
.wle-type.is-active {
|
||||
border-color: rgba(22, 119, 255, 0.6);
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
.wle-type.is-empty {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.wle-type__count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--sycm-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wle-type__clear {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ============ 主从两栏 ============ */
|
||||
.wle-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.wle-centers {
|
||||
flex: none;
|
||||
width: 272px;
|
||||
}
|
||||
|
||||
.wle-centers__hint {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wle-centers__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wle-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
color: var(--sycm-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.wle-center:hover {
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.wle-center.is-active {
|
||||
border-color: rgba(22, 119, 255, 0.45);
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
.wle-center.is-clean .wle-center__name {
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.wle-center__name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wle-center__badge {
|
||||
flex: none;
|
||||
min-width: 24px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9px;
|
||||
background: rgba(136, 135, 128, 0.12);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wle-center__badge.is-warn {
|
||||
background: rgba(255, 77, 79, 0.16);
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.wle-detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wle-detail__scope {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.wle-detail__clear {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wle-detail__hours {
|
||||
color: #cf1322;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wle-detail__soid {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wle-foot {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
/* 窄屏:仓库列表改两列网格,明细整行下移 */
|
||||
@media (max-width: 1200px) {
|
||||
.wle-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wle-centers {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wle-centers__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -111,45 +111,6 @@
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* 超时卡下面的「其中 N 单数据未同步」—— 订单表停更时的警示小字(2026-10-28) */
|
||||
.wst-stat__warn {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #cf1322;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* 停更告警里逐个源列出「最后同步时间 / 滞后多久」 */
|
||||
.wst-alert__sources {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
.wst-alert__source {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 明细里的「内部单号 + 未同步标记」 */
|
||||
.wst-detail__oid {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wst-detail__stale {
|
||||
margin-inline-end: 0;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
padding-inline: 5px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* ============ 主从两栏 ============ */
|
||||
.wst-body {
|
||||
display: flex;
|
||||
@ -292,70 +253,3 @@
|
||||
gap: 2px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 钉钉通知(两轮核验完成后各推一次)───────────────────────────── */
|
||||
.wst-notify__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wst-notify__label {
|
||||
flex: 0 0 88px;
|
||||
font-size: 13px;
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
.wst-notify__last {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wst-notify__error {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.wst-notify__help {
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
.wst-notify__help code {
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.wst-notify__preview {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--sycm-border, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 8px;
|
||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.wst-notify__preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wst-notify__preview pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
/* 30 条明细大概 40 行,别把整个弹窗撑爆;内部滚动看全 */
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@ -20,15 +20,10 @@ const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
||||
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
|
||||
const WarehouseShipTimeliness = lazy(() => import('../pages/WarehouseShipTimeliness'));
|
||||
const WarehouseLogisticsException = lazy(() => import('../pages/WarehouseLogisticsException'));
|
||||
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 />;
|
||||
|
||||
@ -125,10 +120,6 @@ const router = createBrowserRouter([
|
||||
path: 'planning/operation/warehouse-ship-timeliness',
|
||||
element: renderLazyComponent(WarehouseShipTimeliness),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/warehouse-logistics-exception',
|
||||
element: renderLazyComponent(WarehouseLogisticsException),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/daily-inventory',
|
||||
element: renderLazyComponent(DailyInventory),
|
||||
@ -141,11 +132,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 区分取哪个仓;
|
||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user