Compare commits
23 Commits
codex/clou
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9991b92325 | |||
| 9b1ec36c70 | |||
| 03fd3f97f3 | |||
| a15f289ec6 | |||
| f786a92387 | |||
| 55b755c1d9 | |||
| ac61802b07 | |||
| bdc2cf9fd6 | |||
| b30c4e949c | |||
| 85e2db9f92 | |||
| 9cba2f8140 | |||
| db1ba79416 | |||
| 42f60eb0ce | |||
| f42f461430 | |||
| 4b02ad212d | |||
| 530d1ad27a | |||
| 6833b7bc79 | |||
| 893f4d6aca | |||
| 3d9b12eace | |||
| c9715c0e5b | |||
| c80d28c33f | |||
| 62cac712db | |||
| e5030d4864 |
134
backend/src/api/binReplenish.js
Normal file
134
backend/src/api/binReplenish.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const {
|
||||||
|
clearPickBins,
|
||||||
|
getPickBinMeta,
|
||||||
|
getReplenishExportRows,
|
||||||
|
getReplenishList,
|
||||||
|
importPickBins,
|
||||||
|
resolveWarehouseKey,
|
||||||
|
} = require('../services/binReplenishService');
|
||||||
|
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'targetQty', title: '目标拣货位库存' },
|
||||||
|
{ key: 'suggestQty', title: '建议补货量' },
|
||||||
|
{ key: 'avgSale', title: '近15天日销' },
|
||||||
|
{ key: 'pickBinText', title: '拣货位(货位号/数量)' },
|
||||||
|
{ key: 'storageBinText', title: '存货位(前5个/数量)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
targetDays: req.query.targetDays,
|
||||||
|
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,
|
||||||
|
targetDays: req.query.targetDays,
|
||||||
|
});
|
||||||
|
const rows = payload.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
pickBinText: (row.pickBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
|
||||||
|
storageBinText: (row.storageBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
sendWorkbookDownload(res, {
|
||||||
|
fileName: `${payload.warehouse.shortName}缺货补货监控_${new Date().toISOString().slice(0, 10)}`,
|
||||||
|
format,
|
||||||
|
sheets: [{
|
||||||
|
name: '缺货补货监控',
|
||||||
|
header: EXPORT_COLUMNS.map((column) => column.title),
|
||||||
|
rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/bin-replenish/pick-bins —— 当前仓库的拣货位清单概况 */
|
||||||
|
router.get('/pick-bins', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const meta = await getPickBinMeta(resolveWarehouseKey(req.query.warehouse));
|
||||||
|
res.json({ success: true, data: meta });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/bin-replenish/pick-bins —— 导入拣货位清单(xlsx / xls / csv / txt) */
|
||||||
|
router.post('/pick-bins', upload.single('file'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file || !req.file.buffer) {
|
||||||
|
res.status(400).json({ success: false, message: '请选择要导入的货位清单文件(xlsx / xls / csv)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// multer 按 latin1 解析 multipart 文件名,中文会乱码,这里转回 UTF-8
|
||||||
|
const rawName = req.file.originalname || '';
|
||||||
|
const fileName = /[\u00c0-\u00ff]/.test(rawName)
|
||||||
|
? Buffer.from(rawName, 'latin1').toString('utf8')
|
||||||
|
: rawName;
|
||||||
|
|
||||||
|
const result = await importPickBins({
|
||||||
|
buffer: req.file.buffer,
|
||||||
|
fileName,
|
||||||
|
warehouseKey: req.body?.warehouse || req.query.warehouse,
|
||||||
|
importedBy: req.user?.username || req.user?.name || null,
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/bin-replenish/pick-bins —— 清除货位清单,回到回退口径 */
|
||||||
|
router.delete('/pick-bins', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await clearPickBins(req.query.warehouse);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} 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;
|
||||||
173
backend/src/api/warehouseLogisticsException.js
Normal file
173
backend/src/api/warehouseLogisticsException.js
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
'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;
|
||||||
62
backend/src/api/warehouseShipTimeliness.js
Normal file
62
backend/src/api/warehouseShipTimeliness.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* GET /api/warehouse-ship-timeliness/list
|
||||||
|
* query:
|
||||||
|
* round 'NOON' | 'AFTERNOON' 手动指定核验轮次;不传按当前时刻自动判定
|
||||||
|
* limit 明细条数上限(默认 500,最大 2000)
|
||||||
|
*
|
||||||
|
* GET /api/warehouse-ship-timeliness/rounds
|
||||||
|
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
||||||
|
*
|
||||||
|
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
|
||||||
|
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function sendError(res, error, fallback) {
|
||||||
|
const status = Number(error?.statusCode) || 500;
|
||||||
|
console.error('[WarehouseShipTimeliness]', {
|
||||||
|
message: error?.message || fallback,
|
||||||
|
code: error?.code || null,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
res.status(status).json({ success: false, error: error.message || fallback });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时效总览:每个主仓「超时未发」数量 + 超时订单明细
|
||||||
|
router.get('/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getShipTimeliness(req.query || {});
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载仓储中心发货时效失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 仅轮次信息(不查订单)
|
||||||
|
router.get('/rounds', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
rounds: describeRounds(new Date()),
|
||||||
|
centers: CENTERS.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
placeholder: Boolean(c.placeholder),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载核验轮次失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -35,10 +35,13 @@ const promotionModuleRoutes = require('./api/promotionModule');
|
|||||||
const productMonitoringRoutes = require('./api/productMonitoring');
|
const productMonitoringRoutes = require('./api/productMonitoring');
|
||||||
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
|
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
|
||||||
const mkhBomRoutes = require('./api/mkhBom');
|
const mkhBomRoutes = require('./api/mkhBom');
|
||||||
|
const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness');
|
||||||
|
const warehouseLogisticsExceptionRoutes = require('./api/warehouseLogisticsException');
|
||||||
const dailyInventoryRoutes = require('./api/dailyInventory');
|
const dailyInventoryRoutes = require('./api/dailyInventory');
|
||||||
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
|
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
|
||||||
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
|
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
|
||||||
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
|
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
|
||||||
|
const binReplenishRoutes = require('./api/binReplenish');
|
||||||
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
|
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
|
||||||
const returnMonitorRoutes = require('./api/returnMonitor');
|
const returnMonitorRoutes = require('./api/returnMonitor');
|
||||||
const returnStockWarningRoutes = require('./api/returnStockWarning');
|
const returnStockWarningRoutes = require('./api/returnStockWarning');
|
||||||
@ -66,6 +69,9 @@ const {
|
|||||||
const {
|
const {
|
||||||
startMkhReplenishAlertScheduler,
|
startMkhReplenishAlertScheduler,
|
||||||
} = require('./jobs/mkhReplenishAlertJob');
|
} = require('./jobs/mkhReplenishAlertJob');
|
||||||
|
const {
|
||||||
|
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||||
|
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
|
||||||
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||||
const { ensureLocalSchema } = require('./services/localSchema');
|
const { ensureLocalSchema } = require('./services/localSchema');
|
||||||
const {
|
const {
|
||||||
@ -672,6 +678,10 @@ async function startServer(options = {}) {
|
|||||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||||
|
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||||
|
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', authMiddleware, dailyInventoryRoutes);
|
||||||
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
|
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
|
||||||
cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes);
|
cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes);
|
||||||
@ -681,6 +691,8 @@ async function startServer(options = {}) {
|
|||||||
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
|
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
|
||||||
// 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓)
|
// 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓)
|
||||||
cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes);
|
cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes);
|
||||||
|
// 缺货补货监控(拣货位缺货 → 从存货位补货)
|
||||||
|
cubejsApp.use('/api/bin-replenish', authMiddleware, binReplenishRoutes);
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// 数据分析 API(公开访问,可根据需要添加认证)
|
// 数据分析 API(公开访问,可根据需要添加认证)
|
||||||
@ -785,6 +797,7 @@ async function startServer(options = {}) {
|
|||||||
startGoodsFirstSeenScheduler();
|
startGoodsFirstSeenScheduler();
|
||||||
startAlibaba1688ImageSearchExpiryScheduler();
|
startAlibaba1688ImageSearchExpiryScheduler();
|
||||||
startMkhReplenishAlertScheduler();
|
startMkhReplenishAlertScheduler();
|
||||||
|
startWarehouseLogisticsExceptionNotifyScheduler();
|
||||||
startDailyInventorySnapshotScheduler();
|
startDailyInventorySnapshotScheduler();
|
||||||
|
|
||||||
// Keep the first selection-pool page request from paying the remote schema
|
// Keep the first selection-pool page request from paying the remote schema
|
||||||
|
|||||||
433
backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js
Normal file
433
backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 业务口径(2026-10-22 定稿):
|
||||||
|
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
|
||||||
|
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
|
||||||
|
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
|
||||||
|
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
|
||||||
|
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
|
||||||
|
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
|
||||||
|
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
|
||||||
|
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
|
||||||
|
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
|
||||||
|
*
|
||||||
|
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cron = require('node-cron');
|
||||||
|
const { getLogisticsExceptionExportRows } = require('../services/warehouseLogisticsExceptionService');
|
||||||
|
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
||||||
|
const {
|
||||||
|
DEFAULT_MAX_DETAIL_ROWS,
|
||||||
|
DEFAULT_PAGE_URL,
|
||||||
|
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
|
||||||
|
isWarehouseLogisticsExceptionNotifyReady,
|
||||||
|
loadWarehouseLogisticsExceptionNotifyState,
|
||||||
|
saveWarehouseLogisticsExceptionNotifyState,
|
||||||
|
} = require('../services/warehouseLogisticsExceptionNotifySettings');
|
||||||
|
|
||||||
|
const TIMEZONE = 'Asia/Shanghai';
|
||||||
|
// 每分钟醒一次,比对「现在是不是配置的推送时刻」—— 这样业务在页面上改时间不用重启后端
|
||||||
|
const DEFAULT_TICK_CRON = '* * * * *';
|
||||||
|
// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败
|
||||||
|
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
||||||
|
|
||||||
|
let tickRunning = false;
|
||||||
|
|
||||||
|
function pad2(value) {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 北京时间的 YYYY-MM-DD */
|
||||||
|
function chinaDateKey(date = new Date()) {
|
||||||
|
const base = date instanceof Date ? date : new Date(date);
|
||||||
|
if (!Number.isFinite(base.getTime())) return '';
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: TIMEZONE,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 北京时间的 HH:mm —— 用来跟配置里的 sendTime 比对 */
|
||||||
|
function chinaHourMinute(date = new Date()) {
|
||||||
|
const base = date instanceof Date ? date : new Date(date);
|
||||||
|
if (!Number.isFinite(base.getTime())) return '';
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: TIMEZONE,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段清洗:换行/连续空白压成单空格。
|
||||||
|
* ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。
|
||||||
|
*/
|
||||||
|
function cleanCell(value, fallback = '') {
|
||||||
|
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||||
|
return text || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '2026-10-21 16:00:00' → '10-21 16:00';空值给「—」 */
|
||||||
|
function shortTime(value) {
|
||||||
|
const text = cleanCell(value);
|
||||||
|
if (!text) return '—';
|
||||||
|
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||||||
|
if (!match) return text.slice(0, 16);
|
||||||
|
return `${match[2]}-${match[3]} ${match[4]}:${match[5]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHours(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return '—';
|
||||||
|
return `${Math.round(num * 10) / 10}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 明细一行:仓|内部单号|快递|异常类型|发货时间|待处理 */
|
||||||
|
function buildDetailLine(row, index) {
|
||||||
|
const parts = [
|
||||||
|
cleanCell(row?.warehouseName, '未知仓'),
|
||||||
|
cleanCell(row?.oId, '无单号'),
|
||||||
|
cleanCell(row?.carrier, '未知快递'),
|
||||||
|
cleanCell(row?.exceptionLabel, '异常'),
|
||||||
|
shortTime(row?.sendTime),
|
||||||
|
formatHours(row?.pendingHours),
|
||||||
|
];
|
||||||
|
return `${index}. ${parts.join('|')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/
|
||||||
|
function buildCenterLines(data = {}) {
|
||||||
|
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||||
|
const packagesByCenter = rows.reduce((acc, row) => {
|
||||||
|
const key = String(row?.centerKey || '');
|
||||||
|
if (!key) return acc;
|
||||||
|
acc[key] = (acc[key] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (data.centers || [])
|
||||||
|
.map((center) => ({
|
||||||
|
name: cleanCell(center.name || center.key, '未知仓'),
|
||||||
|
exceptionPackages: packagesByCenter[center.key] || 0,
|
||||||
|
shippedOrders: Number(center.shippedOrders) || 0,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.exceptionPackages > 0)
|
||||||
|
.sort((a, b) => b.exceptionPackages - a.exceptionPackages)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((item, index) => `${index + 1}. ${item.name} ${item.exceptionPackages} 个(发货 ${item.shippedOrders} 单)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTypeLine(data = {}) {
|
||||||
|
const summary = data.summary || {};
|
||||||
|
const entries = Object.entries(summary.byType || {});
|
||||||
|
if (!entries.length) return '';
|
||||||
|
return entries
|
||||||
|
.map(([type, count]) => {
|
||||||
|
const meta = (data.types || []).find((item) => item.key === type);
|
||||||
|
return { label: meta?.label || type, count: Number(count) || 0 };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.map((item) => `${item.label} ${item.count}`)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageLink(pageUrl, verifyDate) {
|
||||||
|
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
||||||
|
if (!verifyDate) return base;
|
||||||
|
const glue = base.includes('?') ? '&' : '?';
|
||||||
|
return `${base}${glue}date=${encodeURIComponent(verifyDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装群消息正文(纯函数,便于单测)。
|
||||||
|
* 结构:抬头 → 数字 → 分类型 → 按仓 → 明细前 N 条 → 页面链接
|
||||||
|
* 超长处理:从明细末尾往前砍,砍到能发为止(绝不让钉钉 400 把整条消息吞掉)。
|
||||||
|
*
|
||||||
|
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
|
||||||
|
*/
|
||||||
|
function buildNotifyText(data = {}, options = {}) {
|
||||||
|
const batch = data.batch || {};
|
||||||
|
const summary = data.summary || {};
|
||||||
|
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||||
|
const exclusions = data.exclusions || {};
|
||||||
|
|
||||||
|
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
|
||||||
|
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
|
||||||
|
: DEFAULT_MAX_DETAIL_ROWS;
|
||||||
|
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, batch.verifyDate);
|
||||||
|
|
||||||
|
const totalPackages = Number(summary.exceptionPackages) || rows.length;
|
||||||
|
const shipped = Number(summary.shippedOrders) || 0;
|
||||||
|
const ratio = Number.isFinite(Number(summary.exceptionRate)) ? `${Number(summary.exceptionRate)}%` : '—';
|
||||||
|
|
||||||
|
const head = [
|
||||||
|
`【仓储物流转运异常】${cleanCell(batch.label, '本批核验')}`,
|
||||||
|
`已发货 ${shipped} 单 / ${Number(summary.packageCount) || 0} 个包裹,异常 ${totalPackages} 个包裹(${ratio})`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const typeLine = buildTypeLine(data);
|
||||||
|
if (typeLine) head.push(`类型:${typeLine}`);
|
||||||
|
|
||||||
|
if (totalPackages === 0) {
|
||||||
|
head.push('本批无转运异常,全部正常。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||||||
|
+ (Number(exclusions.voidedPackages) || 0)
|
||||||
|
+ (Number(exclusions.nonRealPackages) || 0);
|
||||||
|
if (excludedCount > 0) {
|
||||||
|
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||||
|
const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1));
|
||||||
|
const wantCount = Math.min(maxDetailRows, detailAll.length);
|
||||||
|
|
||||||
|
// 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出)
|
||||||
|
const buildTail = (listed) => {
|
||||||
|
const lines = [];
|
||||||
|
if (totalPackages > 0) {
|
||||||
|
if (listed === 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`明细共 ${totalPackages} 条,消息里不放,请点下面链接查看。`);
|
||||||
|
} else if (totalPackages > listed) {
|
||||||
|
lines.push(`…其余 ${totalPackages - listed} 条见页面。`);
|
||||||
|
}
|
||||||
|
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assemble = (listed, withCenters, extraNote = '') => {
|
||||||
|
const blocks = [...head];
|
||||||
|
if (withCenters && centerLines.length) {
|
||||||
|
blocks.push('');
|
||||||
|
blocks.push('按仓(异常包裹):');
|
||||||
|
blocks.push(...centerLines);
|
||||||
|
}
|
||||||
|
if (listed > 0) {
|
||||||
|
blocks.push('');
|
||||||
|
blocks.push(`明细前 ${listed} 条:`);
|
||||||
|
blocks.push(...detailAll.slice(0, listed));
|
||||||
|
}
|
||||||
|
const tail = buildTail(listed);
|
||||||
|
if (tail.length) blocks.push(...tail);
|
||||||
|
if (extraNote) blocks.push(extraNote);
|
||||||
|
const text = blocks.join('\n');
|
||||||
|
return { text, bytes: Buffer.byteLength(text, 'utf8') };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 先按期望条数拼;超长就从明细末尾往前砍(先砍明细,再砍分仓块)
|
||||||
|
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」——
|
||||||
|
// 后者是正常行为(listedCount < rowCount),混在一起会让页面误报「超长精简」。
|
||||||
|
let listed = wantCount;
|
||||||
|
let withCenters = centerLines.length > 0;
|
||||||
|
let result = assemble(listed, withCenters);
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) {
|
||||||
|
listed -= 1;
|
||||||
|
truncated = true;
|
||||||
|
result = assemble(listed, withCenters);
|
||||||
|
}
|
||||||
|
if (result.bytes > SAFE_CONTENT_BYTES && withCenters) {
|
||||||
|
withCenters = false;
|
||||||
|
result = assemble(listed, withCenters);
|
||||||
|
}
|
||||||
|
if (result.bytes > SAFE_CONTENT_BYTES) {
|
||||||
|
// 极端情况(仓名/链接特别长):硬截断,至少保证消息能发出去
|
||||||
|
const chars = Array.from(result.text);
|
||||||
|
let bytes = 0;
|
||||||
|
let cut = '';
|
||||||
|
for (const char of chars) {
|
||||||
|
const size = Buffer.byteLength(char, 'utf8');
|
||||||
|
if (bytes + size > SAFE_CONTENT_BYTES) break;
|
||||||
|
bytes += size;
|
||||||
|
cut += char;
|
||||||
|
}
|
||||||
|
result = { text: cut, bytes };
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: result.text,
|
||||||
|
rowCount: totalPackages,
|
||||||
|
listedCount: listed,
|
||||||
|
bytes: result.bytes,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行一次推送。参数:
|
||||||
|
* now 指定时间(测试用)
|
||||||
|
* date 指定核验日 'YYYY-MM-DD'
|
||||||
|
* force 忽略「今天已经推过」的限制(页面「立即推送」用)
|
||||||
|
* dryRun 只组装消息不发送(预览 / 单测用)
|
||||||
|
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
||||||
|
*/
|
||||||
|
async function pushWarehouseLogisticsExceptionNotify(options = {}) {
|
||||||
|
const now = options.now === undefined ? new Date() : new Date(options.now);
|
||||||
|
const pushDate = chinaDateKey(now);
|
||||||
|
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
|
||||||
|
|
||||||
|
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
||||||
|
if (options.probe === true) {
|
||||||
|
if (!isWarehouseLogisticsExceptionNotifyReady(config)) {
|
||||||
|
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||||||
|
}
|
||||||
|
const delivery = await sendTextToWebhook({
|
||||||
|
webhookUrl: config.webhookUrl,
|
||||||
|
secret: config.webhookSecret,
|
||||||
|
content: '【仓储物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。',
|
||||||
|
});
|
||||||
|
return { sent: true, probe: true, delivery };
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadWarehouseLogisticsExceptionNotifyState();
|
||||||
|
if (state?.lastSentDate === pushDate && options.force !== true) {
|
||||||
|
return { sent: false, reason: 'already_sent_today', pushDate, lastSentAt: state.lastSentAt || null };
|
||||||
|
}
|
||||||
|
if (!config.enabled && options.force !== true) {
|
||||||
|
return { sent: false, reason: 'disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dryRun = options.dryRun === true;
|
||||||
|
if (!dryRun && !isWarehouseLogisticsExceptionNotifyReady(config)) {
|
||||||
|
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getLogisticsExceptionExportRows({ date: options.date });
|
||||||
|
if (data?.notStarted) {
|
||||||
|
// 还没到核验时刻(例如把推送时间设在 09:00 之前)→ 不推空数据,等下一轮或人工
|
||||||
|
return { sent: false, reason: 'not_started', batch: data.batch || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = buildNotifyText(data, {
|
||||||
|
maxDetailRows: config.maxDetailRows,
|
||||||
|
pageUrl: config.pageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
sent: false,
|
||||||
|
reason: 'dry_run',
|
||||||
|
text: built.text,
|
||||||
|
rowCount: built.rowCount,
|
||||||
|
listedCount: built.listedCount,
|
||||||
|
bytes: built.bytes,
|
||||||
|
truncated: built.truncated,
|
||||||
|
batch: data.batch || null,
|
||||||
|
summary: data.summary || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const delivery = await sendTextToWebhook({
|
||||||
|
webhookUrl: config.webhookUrl,
|
||||||
|
secret: config.webhookSecret,
|
||||||
|
content: built.text,
|
||||||
|
atAll: config.atAll === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveWarehouseLogisticsExceptionNotifyState({
|
||||||
|
lastSentDate: pushDate,
|
||||||
|
lastSentAt: new Date().toISOString(),
|
||||||
|
lastStatus: 'sent',
|
||||||
|
lastError: '',
|
||||||
|
rowCount: built.rowCount,
|
||||||
|
listedCount: built.listedCount,
|
||||||
|
bytes: built.bytes,
|
||||||
|
truncated: built.truncated,
|
||||||
|
verifyDate: data.batch?.verifyDate || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sent: true,
|
||||||
|
rowCount: built.rowCount,
|
||||||
|
listedCount: built.listedCount,
|
||||||
|
bytes: built.bytes,
|
||||||
|
truncated: built.truncated,
|
||||||
|
text: built.text,
|
||||||
|
batch: data.batch || null,
|
||||||
|
summary: data.summary || null,
|
||||||
|
delivery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', options = {}) {
|
||||||
|
try {
|
||||||
|
const result = await pushWarehouseLogisticsExceptionNotify(options);
|
||||||
|
if (result.sent) {
|
||||||
|
console.log(`[WleNotify] ${source} 已推送(${result.rowCount} 条异常,列出 ${result.listedCount} 条,${result.bytes} 字节)`);
|
||||||
|
} else {
|
||||||
|
console.log(`[WleNotify] ${source} 跳过: ${result.reason}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[WleNotify] ${source} 执行失败:`, error.message || error);
|
||||||
|
try {
|
||||||
|
await saveWarehouseLogisticsExceptionNotifyState({
|
||||||
|
...(await loadWarehouseLogisticsExceptionNotifyState()),
|
||||||
|
lastError: String(error.message || error).slice(0, 500),
|
||||||
|
lastFailedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (_ignored) { /* 状态写失败不影响主流程 */ }
|
||||||
|
return { sent: false, reason: 'error', error: error.message || String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。
|
||||||
|
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
|
||||||
|
*/
|
||||||
|
function startWarehouseLogisticsExceptionNotifyScheduler() {
|
||||||
|
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
|
||||||
|
if (disabled) {
|
||||||
|
console.log('[Scheduler] 物流转运异常钉钉推送已禁用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
||||||
|
cron.schedule(expression, () => {
|
||||||
|
if (tickRunning) return;
|
||||||
|
tickRunning = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
|
||||||
|
if (!config.enabled) return;
|
||||||
|
if (chinaHourMinute(now) !== config.sendTime) return;
|
||||||
|
await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WleNotify] 定时检查失败:', error.message || error);
|
||||||
|
} finally {
|
||||||
|
tickRunning = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, { timezone: TIMEZONE });
|
||||||
|
|
||||||
|
console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_TICK_CRON,
|
||||||
|
SAFE_CONTENT_BYTES,
|
||||||
|
TIMEZONE,
|
||||||
|
buildCenterLines,
|
||||||
|
buildDetailLine,
|
||||||
|
buildNotifyText,
|
||||||
|
buildTypeLine,
|
||||||
|
chinaDateKey,
|
||||||
|
chinaHourMinute,
|
||||||
|
cleanCell,
|
||||||
|
pushWarehouseLogisticsExceptionNotify,
|
||||||
|
runWarehouseLogisticsExceptionNotifyJob,
|
||||||
|
shortTime,
|
||||||
|
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||||
|
};
|
||||||
53
backend/src/services/binReplenishSchema.js
Normal file
53
backend/src/services/binReplenishSchema.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
'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 };
|
||||||
613
backend/src/services/binReplenishService.js
Normal file
613
backend/src/services/binReplenishService.js
Normal file
@ -0,0 +1,613 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缺货补货监控 —— 拣货位缺货 / 从存货位补货
|
||||||
|
* ==========================================================================
|
||||||
|
* 业务需求(2026-10-22 用户确认):
|
||||||
|
* 监控现有库存 → 看哪些「拣货库位」缺货 → 需要从「存货仓位」补货过去。
|
||||||
|
*
|
||||||
|
* ── 数据源 ───────────────────────────────────────────────────────────
|
||||||
|
* rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」)
|
||||||
|
* · 列:sku_id / bin(货位号)/ pack_type / bin_group / qty / wms_co_id
|
||||||
|
* · 一行 = 某货位上某商品的库存;同一货位可能多行(不同箱)
|
||||||
|
* · pack_type:Bin = 货位散件库存;Pack = 整箱;DefaultPack = 未分箱(仓库级)
|
||||||
|
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
|
||||||
|
* 崔桥/山东/横林二/苏北/河南全部挂在 wh_id=1 下,按 wh_id 会混成一个桶。
|
||||||
|
* ⚠️ 该对象在副本里是**视图**(sys.objects.type_desc = VIEW),
|
||||||
|
* 没有暴露底层表;单次按 SKU+货位聚合实测 **10~45 秒**(随缓存/负载波动),
|
||||||
|
* 直接取原始行更慢(25,488 行约 80 秒)。因此必须缓存 + 启动预热,
|
||||||
|
* 不要把它改回「每次请求都回源」。
|
||||||
|
*
|
||||||
|
* ── 拣货位怎么认定(关键)─────────────────────────────────────────────
|
||||||
|
* 副本**没有独立的货位类型主数据**,但「拣选库位动销分析」已确认口径:
|
||||||
|
* 真实拣选位 = `pack_type = 'Bin'` 且 `bin_group` 为空
|
||||||
|
* (`bin_group='存货'`、`pack_type='Pack'/'DefaultPack'` 都算存货位)
|
||||||
|
* 本页默认就用这个口径;如果业务另有一份拣货位清单,
|
||||||
|
* 页面导入后以清单为准(`bin ∈ 清单` → 拣货位),其余一律算存货位。
|
||||||
|
*
|
||||||
|
* ── 指标口径 ─────────────────────────────────────────────────────────
|
||||||
|
* 拣货位库存 pickQty = 该 SKU 在所有拣货位上的 qty 合计
|
||||||
|
* 存货位库存 storageQty = 该 SKU 在其余货位(含整箱/未分箱)上的 qty 合计
|
||||||
|
* 现有库存 totalQty = pickQty + storageQty
|
||||||
|
* 目标拣货位库存 target = max(10, ceil(近15天日销 × targetDays)),targetDays 默认 7
|
||||||
|
* 建议补货量 = min(storageQty, max(0, target − pickQty)),向上取整
|
||||||
|
*
|
||||||
|
* 状态:
|
||||||
|
* pickout 拣货位缺货(pickQty ≤ 0,且存货位有货 → 可以补)
|
||||||
|
* empty 无货可补(pickQty ≤ 0,存货位也没货 → 需要采购)
|
||||||
|
* low 拣货位偏低(0 < pickQty < target,存货位有货)
|
||||||
|
* ok 正常
|
||||||
|
*
|
||||||
|
* 近15天日销取自 rpt.pm_stockMonitor(同仓最新快照的 fifteenAvgSale),
|
||||||
|
* 只用于算目标和排序;取不到时目标按 10 件兜底。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const XLSX = require('xlsx');
|
||||||
|
const cloudDb = require('../config/cloudDatabase');
|
||||||
|
const database = require('../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓库清单:**按 wms_co_id 取数,不能用 wh_id**
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* 2026-10-22 实测:jst_packInventoryInfo.wh_id 是「货主」(简尚=1),
|
||||||
|
* 崔桥/山东/横林二/苏北/河南的仓位记录**全部挂在 wh_id=1 底下**,
|
||||||
|
* 按 wh_id 汇总会把 10 个仓混成一个桶。真实仓库是 wms_co_id。
|
||||||
|
* 这里只列「有真实拣选位」的仓(拣选位 = pack_type='Bin' 且 bin_group 为空),
|
||||||
|
* 口径与「拣选库位动销分析」保持一致。
|
||||||
|
*/
|
||||||
|
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_TARGET_DAYS = 7;
|
||||||
|
const MIN_TARGET_QTY = 10;
|
||||||
|
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
|
||||||
|
const MAX_PAGE_SIZE = 5000;
|
||||||
|
|
||||||
|
const STATUS = Object.freeze({
|
||||||
|
PICK_OUT: 'pickout',
|
||||||
|
EMPTY: 'empty',
|
||||||
|
LOW: 'low',
|
||||||
|
NO_PICK: 'nopick',
|
||||||
|
OK: 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_LABELS = Object.freeze({
|
||||||
|
pickout: '拣货位缺货',
|
||||||
|
empty: '无货可补',
|
||||||
|
low: '拣货位偏低',
|
||||||
|
nopick: '未设拣货位',
|
||||||
|
ok: '正常',
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_RANK = Object.freeze({ pickout: 4, empty: 3, low: 2, nopick: 1, ok: 0 });
|
||||||
|
|
||||||
|
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 normalizeBin(value) {
|
||||||
|
return toText(value, 64).toUpperCase().replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTargetDays(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num) || num <= 0) return DEFAULT_TARGET_DAYS;
|
||||||
|
return Math.min(Math.round(num), 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 拣货位清单(本地库)────────────────────────────────────────────
|
||||||
|
async function loadPickBinRows(warehouseKey) {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
'SELECT bin_code AS binCode, imported_at AS importedAt FROM bin_replenish_pick_bin WHERE warehouse_key = ?',
|
||||||
|
[warehouseKey]
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLatestPickBinBatch(warehouseKey) {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT batch_id AS batchId, file_name AS fileName, imported_rows AS importedRows,
|
||||||
|
imported_by AS importedBy, imported_at AS importedAt
|
||||||
|
FROM bin_replenish_import_batch WHERE warehouse_key = ?
|
||||||
|
ORDER BY imported_at DESC LIMIT 1`,
|
||||||
|
[warehouseKey]
|
||||||
|
);
|
||||||
|
return rows?.[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPickBinMeta(warehouseKey) {
|
||||||
|
const [bins, batch] = await Promise.all([
|
||||||
|
loadPickBinRows(warehouseKey),
|
||||||
|
loadLatestPickBinBatch(warehouseKey),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
count: bins.length,
|
||||||
|
sample: bins.slice(0, 20).map((row) => row.binCode),
|
||||||
|
lastImport: batch,
|
||||||
|
source: bins.length > 0 ? 'import' : 'fallback',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从任意表格/文本里抽取货位号:扫描所有单元格,按逗号/分号/换行拆开,保留 ASCII 货位形态 */
|
||||||
|
const BIN_TOKEN_PATTERN = /^[0-9A-Z]+(?:-[0-9A-Z]+){0,5}$/;
|
||||||
|
// 表头 / 常见非货位词,避免把标题行当成货位导进来
|
||||||
|
const BIN_TOKEN_BLOCKLIST = new Set(['BIN', 'CODE', 'SKU', 'SNO', 'NO', 'ID', 'LOCATION', 'LOC', 'POSITION']);
|
||||||
|
|
||||||
|
function extractBinCodes(matrix = []) {
|
||||||
|
const codes = new Set();
|
||||||
|
let candidates = 0;
|
||||||
|
matrix.forEach((row) => {
|
||||||
|
(Array.isArray(row) ? row : [row]).forEach((cell) => {
|
||||||
|
if (cell === null || cell === undefined) return;
|
||||||
|
if (cell instanceof Date) return;
|
||||||
|
const text = String(cell).trim();
|
||||||
|
if (!text) return;
|
||||||
|
const tokens = text.split(/[,,;;\s\r\n\t|、]+/u).filter(Boolean);
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
const code = normalizeBin(token);
|
||||||
|
if (!code) return;
|
||||||
|
candidates += 1;
|
||||||
|
if (code.length < 3 || code.length > 64) return;
|
||||||
|
if (BIN_TOKEN_BLOCKLIST.has(code)) return;
|
||||||
|
if (!BIN_TOKEN_PATTERN.test(code)) return;
|
||||||
|
// 纯数字/纯字母也放过(有些仓用 A01、1234 这种),但单个字符的不要
|
||||||
|
if (/^\d+$/.test(code) && code.length < 2) return;
|
||||||
|
codes.add(code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { codes: [...codes], candidates };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPickBins({ buffer, fileName, warehouseKey, importedBy }) {
|
||||||
|
if (!buffer || !buffer.length) {
|
||||||
|
const error = new Error('文件内容为空,请重新选择');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const key = resolveWarehouseKey(warehouseKey);
|
||||||
|
let workbook;
|
||||||
|
try {
|
||||||
|
workbook = XLSX.read(buffer, { type: 'buffer', cellDates: false });
|
||||||
|
} catch (error) {
|
||||||
|
const err = new Error(`文件解析失败:${error.message}`);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matrix = [];
|
||||||
|
workbook.SheetNames.forEach((sheetName) => {
|
||||||
|
const sheet = workbook.Sheets[sheetName];
|
||||||
|
if (!sheet) return;
|
||||||
|
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false });
|
||||||
|
rows.forEach((row) => matrix.push(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { codes, candidates } = extractBinCodes(matrix);
|
||||||
|
if (!codes.length) {
|
||||||
|
const error = new Error('没有识别到货位号。清单里请只放货位号(如 XA01-01-11),或把它放在单独一列。');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchId = crypto.randomBytes(8).toString('hex');
|
||||||
|
await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]);
|
||||||
|
|
||||||
|
const CHUNK = 500;
|
||||||
|
for (let i = 0; i < codes.length; i += CHUNK) {
|
||||||
|
const chunk = codes.slice(i, i + CHUNK);
|
||||||
|
const placeholders = chunk.map(() => '(?,?,?,NOW())').join(',');
|
||||||
|
const params = [];
|
||||||
|
chunk.forEach((code) => {
|
||||||
|
params.push(key, code, batchId);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO bin_replenish_pick_bin (warehouse_key, bin_code, batch_id, imported_at) VALUES ${placeholders}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO bin_replenish_import_batch
|
||||||
|
(batch_id, warehouse_key, file_name, total_cells, imported_rows, imported_by, imported_at)
|
||||||
|
VALUES (?,?,?,?,?,?,NOW())`,
|
||||||
|
[batchId, key, toText(fileName, 255) || null, candidates, codes.length, toText(importedBy, 100) || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
invalidateCache(key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
batchId,
|
||||||
|
warehouse: WAREHOUSES[key],
|
||||||
|
importedRows: codes.length,
|
||||||
|
totalCells: candidates,
|
||||||
|
fileName: toText(fileName, 255) || null,
|
||||||
|
sample: codes.slice(0, 20),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPickBins(warehouseKey) {
|
||||||
|
const key = resolveWarehouseKey(warehouseKey);
|
||||||
|
const rows = await database.local.query(
|
||||||
|
'SELECT COUNT(*) AS cnt FROM bin_replenish_pick_bin WHERE warehouse_key = ?',
|
||||||
|
[key]
|
||||||
|
);
|
||||||
|
await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]);
|
||||||
|
invalidateCache(key);
|
||||||
|
return { warehouse: WAREHOUSES[key], removed: Number(rows?.[0]?.cnt || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 副本取数 ────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* 按 SKU + 货位 + 记录类型聚合。
|
||||||
|
* 必须用 GROUP BY(不要把原始行拉回进程):视图原始行 25,488 行约 80s,
|
||||||
|
* 聚合后 23,751 行约 43s,是当前可用的最快形态。
|
||||||
|
*/
|
||||||
|
async function loadReplicaRows(warehouse) {
|
||||||
|
const sql = `
|
||||||
|
SELECT p.sku_id AS skuId,
|
||||||
|
p.bin AS bin,
|
||||||
|
p.pack_type AS packType,
|
||||||
|
p.bin_group AS binGroup,
|
||||||
|
SUM(p.qty) AS qty,
|
||||||
|
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
|
||||||
|
GROUP BY p.sku_id, p.bin, p.pack_type, p.bin_group`;
|
||||||
|
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 近15天日销(同仓最新快照),只用于算目标库存与排序 */
|
||||||
|
async function loadDailySaleMap(warehouse) {
|
||||||
|
const rows = await cloudDb.query(
|
||||||
|
`SELECT sku_id AS skuId, fifteenAvgSale AS avgSale
|
||||||
|
FROM rpt.pm_stockMonitor
|
||||||
|
WHERE warehouse = @warehouseName
|
||||||
|
AND CONVERT(varchar(10), date, 23) = (
|
||||||
|
SELECT CONVERT(varchar(10), MAX(date), 23)
|
||||||
|
FROM rpt.pm_stockMonitor WHERE warehouse = @warehouseName
|
||||||
|
)`,
|
||||||
|
{ warehouseName: warehouse.name },
|
||||||
|
{ requestTimeout: 300000 }
|
||||||
|
);
|
||||||
|
const map = new Map();
|
||||||
|
(rows || []).forEach((row) => {
|
||||||
|
const skuId = toText(row.skuId, 120);
|
||||||
|
if (skuId) map.set(skuId, toNumber(row.avgSale));
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 组装 ────────────────────────────────────────────────────────────
|
||||||
|
function buildSkuRows({ replicaRows, pickBinSet, dailySaleMap, targetDays, useFallback }) {
|
||||||
|
const skuMap = new Map();
|
||||||
|
|
||||||
|
(replicaRows || []).forEach((row) => {
|
||||||
|
const skuId = toText(row.skuId, 120);
|
||||||
|
if (!skuId) return;
|
||||||
|
const bin = normalizeBin(row.bin);
|
||||||
|
const qty = toNumber(row.qty);
|
||||||
|
const isPick = useFallback
|
||||||
|
// 默认口径与「拣选库位动销分析」一致:真实拣选位 = pack_type='Bin' 且 bin_group 为空
|
||||||
|
// (bin_group='存货' 与 pack_type='Pack' 都是存货,不算拣选位)
|
||||||
|
? (toText(row.packType) === 'Bin' && !toText(row.binGroup))
|
||||||
|
: (bin ? pickBinSet.has(bin) : false);
|
||||||
|
|
||||||
|
let entry = skuMap.get(skuId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
skuId,
|
||||||
|
productName: toText(row.productName, 300) || '',
|
||||||
|
supplier: toText(row.supplier, 120) || '',
|
||||||
|
pickQty: 0,
|
||||||
|
storageQty: 0,
|
||||||
|
pickBins: [],
|
||||||
|
storageBins: [],
|
||||||
|
};
|
||||||
|
skuMap.set(skuId, entry);
|
||||||
|
}
|
||||||
|
if (!entry.productName) entry.productName = toText(row.productName, 300) || '';
|
||||||
|
if (!entry.supplier) entry.supplier = toText(row.supplier, 120) || '';
|
||||||
|
|
||||||
|
if (isPick) {
|
||||||
|
entry.pickQty += qty;
|
||||||
|
entry.pickBins.push({ bin: bin || '未维护', qty });
|
||||||
|
} else {
|
||||||
|
entry.storageQty += qty;
|
||||||
|
entry.storageBins.push({ bin: bin || '未维护', qty });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
skuMap.forEach((entry) => {
|
||||||
|
const avgSale = toNumber(dailySaleMap.get(entry.skuId), 0);
|
||||||
|
const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(avgSale * targetDays));
|
||||||
|
const pickQty = round2(entry.pickQty);
|
||||||
|
const storageQty = round2(entry.storageQty);
|
||||||
|
|
||||||
|
let status = STATUS.OK;
|
||||||
|
if (entry.pickBins.length === 0) {
|
||||||
|
// 这个 SKU 压根没有拣货位(整箱直发 / 未维护)——不是「拣货位缺货」,
|
||||||
|
// 不能混进待补货清单,否则会刷出几千条误报。
|
||||||
|
status = STATUS.NO_PICK;
|
||||||
|
} else if (pickQty <= 0) {
|
||||||
|
status = storageQty > 0 ? STATUS.PICK_OUT : STATUS.EMPTY;
|
||||||
|
} else if (pickQty < targetQty && storageQty > 0) {
|
||||||
|
status = STATUS.LOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestQty = status === STATUS.PICK_OUT || status === STATUS.LOW
|
||||||
|
? Math.max(0, Math.min(Math.ceil(storageQty), Math.ceil(targetQty - pickQty)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const pickBins = entry.pickBins
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.qty - a.qty || a.bin.localeCompare(b.bin));
|
||||||
|
const storageBins = entry.storageBins
|
||||||
|
.filter((item) => item.qty > 0)
|
||||||
|
.sort((a, b) => b.qty - a.qty)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
skuId: entry.skuId,
|
||||||
|
productName: entry.productName,
|
||||||
|
supplier: entry.supplier,
|
||||||
|
status,
|
||||||
|
statusLabel: STATUS_LABELS[status],
|
||||||
|
pickQty,
|
||||||
|
storageQty,
|
||||||
|
totalQty: round2(pickQty + storageQty),
|
||||||
|
targetQty,
|
||||||
|
suggestQty,
|
||||||
|
avgSale: round2(avgSale),
|
||||||
|
pickBinCount: entry.pickBins.length,
|
||||||
|
pickBins,
|
||||||
|
storageBins,
|
||||||
|
hasPickSlot: entry.pickBins.length > 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按页面传入的目标天数重算目标库存与建议补货量(缓存里存的是默认 7 天口径) */
|
||||||
|
function applyTargetDays(rows, targetDays) {
|
||||||
|
if (targetDays === DEFAULT_TARGET_DAYS) return rows;
|
||||||
|
return rows.map((row) => {
|
||||||
|
const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(row.avgSale * targetDays));
|
||||||
|
const suggestQty = (row.status === STATUS.PICK_OUT || row.status === STATUS.LOW)
|
||||||
|
? Math.max(0, Math.min(Math.ceil(row.storageQty), Math.ceil(targetQty - row.pickQty)))
|
||||||
|
: 0;
|
||||||
|
return { ...row, targetQty, suggestQty };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(rows, meta) {
|
||||||
|
const summary = {
|
||||||
|
skuTotal: rows.length,
|
||||||
|
pickout: 0,
|
||||||
|
empty: 0,
|
||||||
|
low: 0,
|
||||||
|
nopick: 0,
|
||||||
|
ok: 0,
|
||||||
|
suggestQtyTotal: 0,
|
||||||
|
pickoutQtyTotal: 0,
|
||||||
|
storageQtyTotal: 0,
|
||||||
|
withPickSlot: 0,
|
||||||
|
source: meta.source,
|
||||||
|
pickBinCount: meta.pickBinCount,
|
||||||
|
};
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (summary[row.status] !== undefined) summary[row.status] += 1;
|
||||||
|
summary.suggestQtyTotal += row.suggestQty || 0;
|
||||||
|
summary.storageQtyTotal += row.storageQty || 0;
|
||||||
|
if (row.status === STATUS.PICK_OUT) summary.pickoutQtyTotal += row.suggestQty || 0;
|
||||||
|
if (row.hasPickSlot) summary.withPickSlot += 1;
|
||||||
|
});
|
||||||
|
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
|
||||||
|
summary.pickoutQtyTotal = round2(summary.pickoutQtyTotal);
|
||||||
|
summary.storageQtyTotal = round2(summary.storageQtyTotal);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDataset(warehouse, { refresh = false } = {}) {
|
||||||
|
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 [replicaRows, pickBins, dailySaleMap] = await Promise.all([
|
||||||
|
loadReplicaRows(warehouse),
|
||||||
|
loadPickBinRows(warehouse.key),
|
||||||
|
loadDailySaleMap(warehouse).catch(() => new Map()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pickBinSet = new Set(pickBins.map((row) => normalizeBin(row.binCode)).filter(Boolean));
|
||||||
|
const useFallback = pickBinSet.size === 0;
|
||||||
|
const rows = buildSkuRows({
|
||||||
|
replicaRows,
|
||||||
|
pickBinSet,
|
||||||
|
dailySaleMap,
|
||||||
|
targetDays: DEFAULT_TARGET_DAYS,
|
||||||
|
useFallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataset = {
|
||||||
|
at: now,
|
||||||
|
rows,
|
||||||
|
warehouse,
|
||||||
|
meta: {
|
||||||
|
source: useFallback ? 'fallback' : 'import',
|
||||||
|
pickBinCount: pickBinSet.size,
|
||||||
|
dailySaleSkus: dailySaleMap.size,
|
||||||
|
rowCount: rows.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
|
||||||
|
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 matchesStatus(row, status) {
|
||||||
|
const key = toText(status, 20).toLowerCase();
|
||||||
|
if (!key || key === 'all') return true;
|
||||||
|
if (key === 'action' || key === 'todo') {
|
||||||
|
return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW || row.status === STATUS.EMPTY;
|
||||||
|
}
|
||||||
|
if (key === 'replenish') return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW;
|
||||||
|
return row.status === key;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|| String(a.skuId).localeCompare(String(b.skuId))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRows(rows, { keyword, status }) {
|
||||||
|
const kw = toText(keyword, 120).toLowerCase();
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (!matchesStatus(row, status)) 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 targetDays = resolveTargetDays(options.targetDays);
|
||||||
|
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
|
||||||
|
|
||||||
|
// targetDays 是页面参数,不进缓存:缓存的是原始聚合,这里按参数重算目标/建议量
|
||||||
|
const rows = applyTargetDays(dataset.rows, targetDays);
|
||||||
|
|
||||||
|
const filtered = sortRows(filterRows(rows, options));
|
||||||
|
const pickBinMeta = await getPickBinMeta(warehouse.key);
|
||||||
|
const summary = buildSummary(rows, {
|
||||||
|
source: pickBinMeta.source,
|
||||||
|
pickBinCount: pickBinMeta.count,
|
||||||
|
});
|
||||||
|
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: filtered.slice(start, start + pageSize),
|
||||||
|
total: filtered.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
summary,
|
||||||
|
warehouse,
|
||||||
|
warehouses: Object.values(WAREHOUSES),
|
||||||
|
targetDays,
|
||||||
|
cacheHit: dataset.cacheHit,
|
||||||
|
updatedAt: new Date(dataset.at).toISOString(),
|
||||||
|
cacheTtlMs: resolveCacheTtlMs(),
|
||||||
|
pickBinMeta,
|
||||||
|
dailySaleSkus: dataset.meta.dailySaleSkus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReplenishExportRows(options = {}) {
|
||||||
|
const warehouse = resolveWarehouse(options.warehouse);
|
||||||
|
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
|
||||||
|
const targetDays = resolveTargetDays(options.targetDays);
|
||||||
|
const rows = applyTargetDays(dataset.rows, targetDays);
|
||||||
|
return {
|
||||||
|
warehouse,
|
||||||
|
rows: sortRows(filterRows(rows, options)),
|
||||||
|
summary: buildSummary(rows, {
|
||||||
|
source: dataset.meta.source,
|
||||||
|
pickBinCount: dataset.meta.pickBinCount,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动预热:视图查询 35~45s,不能让首个用户请求等 */
|
||||||
|
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,
|
||||||
|
STATUS,
|
||||||
|
STATUS_LABELS,
|
||||||
|
WAREHOUSES,
|
||||||
|
clearPickBins,
|
||||||
|
getPickBinMeta,
|
||||||
|
getReplenishExportRows,
|
||||||
|
getReplenishList,
|
||||||
|
importPickBins,
|
||||||
|
loadDataset,
|
||||||
|
normalizeBin,
|
||||||
|
resolveWarehouse,
|
||||||
|
resolveWarehouseKey,
|
||||||
|
warmBinReplenishCache,
|
||||||
|
};
|
||||||
210
backend/src/services/dingtalkRobotWebhookSender.js
Normal file
210
backend/src/services/dingtalkRobotWebhookSender.js
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉「群自定义机器人」webhook 发文本通道(2026-10-22 新增)
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 为什么不用企业内部应用发文件?
|
||||||
|
* 群机器人 webhook **只能发** text / markdown / link / actionCard / feedCard,
|
||||||
|
* 发 .xlsx 附件必须走企业内部应用(AppKey/AppSecret/robotCode/openConversationId)。
|
||||||
|
* 业务最终选择「群里直接看文本」,于是回到最简单的一条路:
|
||||||
|
* 只要一个 webhook 地址(加签可选),不用申请应用、不用管 token 过期。
|
||||||
|
*
|
||||||
|
* 请求形状(钉钉官方「自定义机器人接入」):
|
||||||
|
* POST https://oapi.dingtalk.com/robot/send?access_token=xxx×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,6 +16,8 @@ const { ensureReturnMonitorSchema } = require('./returnMonitorSchema');
|
|||||||
const {
|
const {
|
||||||
ensureReturnStockWarningSchema,
|
ensureReturnStockWarningSchema,
|
||||||
} = require('./returnStockWarningSchema');
|
} = require('./returnStockWarningSchema');
|
||||||
|
const { ensureBinReplenishSchema } = require('./binReplenishSchema');
|
||||||
|
const { warmBinReplenishCache } = require('./binReplenishService');
|
||||||
const { warmReturnStockWarningCache } = require('./returnStockWarningService');
|
const { warmReturnStockWarningCache } = require('./returnStockWarningService');
|
||||||
|
|
||||||
const USER_COLUMNS = [
|
const USER_COLUMNS = [
|
||||||
@ -959,8 +961,11 @@ async function ensureLocalSchema() {
|
|||||||
await ensureProductMonitoringSchema();
|
await ensureProductMonitoringSchema();
|
||||||
await ensureReturnMonitorSchema();
|
await ensureReturnMonitorSchema();
|
||||||
await ensureReturnStockWarningSchema();
|
await ensureReturnStockWarningSchema();
|
||||||
|
await ensureBinReplenishSchema();
|
||||||
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
|
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
|
||||||
void warmReturnStockWarningCache();
|
void warmReturnStockWarningCache();
|
||||||
|
// 缺货补货监控(仓位库存视图单次查询 35~45s)后台预热,不阻塞启动
|
||||||
|
void warmBinReplenishCache();
|
||||||
|
|
||||||
await ensureSidebarRolePermissions();
|
await ensureSidebarRolePermissions();
|
||||||
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动
|
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动
|
||||||
|
|||||||
@ -70,8 +70,11 @@
|
|||||||
// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货
|
// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货
|
||||||
// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale
|
// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale
|
||||||
// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照)
|
// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照)
|
||||||
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存;
|
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id **仓白名单**)实时库存;
|
||||||
// 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①),
|
// 🔥 2026-10-17 修正为**多仓求和**(原来是单仓 15870495):商品档案 other_3 只记
|
||||||
|
// 「归属仓」,实物会整批搬到同体系其它发货仓 —— 只查归属仓会把有货的算成 0。
|
||||||
|
// 实测新仓「简尚-横林二仓品牌仓」(16132681,10-13 建仓) 58 个 MKH 成品 / 525 件
|
||||||
|
// 全在它名下、归属仓为 0 → 5 个误报缺货 + 2 个误报预警。白名单见 BRAND_WMS_CO_IDS。
|
||||||
// 缺失时回退监控快照 jsStock
|
// 缺失时回退监控快照 jsStock
|
||||||
// · 结余库存 = max(0, 现有库存 − 待发货订单)
|
// · 结余库存 = max(0, 现有库存 − 待发货订单)
|
||||||
// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单**
|
// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单**
|
||||||
@ -215,6 +218,28 @@
|
|||||||
// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的
|
// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的
|
||||||
// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。
|
// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。
|
||||||
//
|
//
|
||||||
|
// ── ⑦-e 成品库存多仓合并(2026-10-17 新增)────────────────────────
|
||||||
|
// 业务原话:「MKHDNZ0409260600788 库存在横林二仓品牌仓,**重新加了一个品牌仓库存**,
|
||||||
|
// 需要同步这个仓库库存」。
|
||||||
|
// 🔥 根因:**商品档案的 `other_3` 记录的是「归属仓」,不是「实物所在仓」**。
|
||||||
|
// 归属仓仍是「简尚-崔桥工厂品牌仓」(15870495),但实物被整批搬到 10-13 新建的
|
||||||
|
// 「简尚-横林二仓品牌仓」(16132681) —— 归属仓库存为 0,新仓有货。
|
||||||
|
// 而 ⑦-d 的取数只看 `wms_co_id = 15870495` 单仓 → 该批商品库存全被算成 0。
|
||||||
|
// 实测:新仓 58 个 SKU 全是 MKH 成品(other_3 全为崔桥品牌仓),合计 525 件;
|
||||||
|
// 线上 49 个 SKU 库存值错误,其中 **5 个误报「缺货」、2 个误报「预警」**。
|
||||||
|
// 规则:`jst_inventoryInfo` / `jst_packInventoryInfo` 都按 **仓白名单 `IN (...)`**
|
||||||
|
// 取数,并 **按 SKU 多仓求和**(同一批货可能同时分散在多仓)。
|
||||||
|
// · 白名单常量:BRAND_WMS_CO_IDS(主仓 + 横林二仓品牌仓)
|
||||||
|
// · ⚠️ 原来的「按 updateTime DESC 取首条」在单仓时是去重,多仓时会**只留一个仓**,
|
||||||
|
// 必须换成 SUM + GROUP BY(本次改动同时修掉这个隐患)。
|
||||||
|
// · ⚠️ 「求和」而非「取最大」:两仓是独立仓位,同一 SKU 同批货不会重复记账
|
||||||
|
// (实测该 SKU 两仓 bin 分别为 `PP01-15-22` 与空,无同箱重复)。
|
||||||
|
// 影响面(2026-10-17 实测):58 个 SKU 受影响,其中 49 个库存值变化;
|
||||||
|
// 预警 30 → 25、缺货 21 → 16(消除 5 个误报缺货、2 个误报预警)。
|
||||||
|
// ⚠️ 新增同类「成品发货仓-真实库存」时,往 BRAND_WMS_CO_IDS 追加 wms_co_id 即可。
|
||||||
|
// ⚠️ 配件库存**不适用本口径**(配件仍只算 15718196)——新仓一个配件都没有,
|
||||||
|
// 横林三仓/沭阳仓虽有 MKH 配件但归属别的项目,并入会让「配件不足」漏报。
|
||||||
|
//
|
||||||
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
|
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
|
||||||
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
|
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
|
||||||
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
|
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
|
||||||
@ -410,8 +435,17 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%'
|
|||||||
)`;
|
)`;
|
||||||
|
|
||||||
// ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
|
// ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
|
||||||
// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得)
|
// 成品库存仓白名单(rpt.jst_warehouseInfo 查得)。**多仓求和**:
|
||||||
const BRAND_WMS_CO_ID = 15870495;
|
// 商品档案的 other_3 只记录「归属仓」(仍是崔桥工厂品牌仓),但实物会成批搬到
|
||||||
|
// 同体系的其它发货仓,此时库存表里只有新仓有数、归属仓为 0 —— 只查归属仓会把
|
||||||
|
// 有货的商品算成 0(实测 2026-10-17:新仓「简尚-横林二仓品牌仓」10-13 建仓,
|
||||||
|
// 58 个 MKH 成品 / 525 件全部只在它名下,导致 5 个误报缺货 + 2 个误报预警)。
|
||||||
|
// · 15870495 简尚-崔桥工厂品牌仓(归属主仓)
|
||||||
|
// · 16132681 简尚-横林二仓品牌仓(2026-10-13 新增,成品发货仓-真实库存)
|
||||||
|
// ⚠️ 新增同类「成品发货仓-真实库存」时把 wms_co_id 追加进来即可,别再写死单仓。
|
||||||
|
const BRAND_WMS_CO_IDS = Object.freeze([15870495, 16132681]);
|
||||||
|
// 兼容旧引用:主仓仍作为「仓库名」文案与监控快照的 warehouse 过滤值
|
||||||
|
const BRAND_WMS_CO_ID = BRAND_WMS_CO_IDS[0];
|
||||||
// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据,
|
// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据,
|
||||||
// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。
|
// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。
|
||||||
// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列,
|
// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列,
|
||||||
@ -537,8 +571,10 @@ const normalizeCode = (value) => {
|
|||||||
// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。
|
// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。
|
||||||
const REPLENISH_PARAMS = Object.freeze({
|
const REPLENISH_PARAMS = Object.freeze({
|
||||||
brandWarehouse: DESIGNATED_WAREHOUSE,
|
brandWarehouse: DESIGNATED_WAREHOUSE,
|
||||||
brandWmsCoId: BRAND_WMS_CO_ID,
|
|
||||||
});
|
});
|
||||||
|
// 成品库存仓白名单的 SQL 片段(纯代码常量插值,不含外部输入)。
|
||||||
|
// 用 `IN (...)` 一条查完多仓,再由应用层按 SKU **求和**分仓结果。
|
||||||
|
const BRAND_WMS_CO_ID_LIST_SQL = BRAND_WMS_CO_IDS.join(',');
|
||||||
// 商品集合是主数据、监控快照为日更,10 分钟缓存足够
|
// 商品集合是主数据、监控快照为日更,10 分钟缓存足够
|
||||||
const MKH_CACHE_TTL_MS = 10 * 60 * 1000;
|
const MKH_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
let mkhProductCache = null;
|
let mkhProductCache = null;
|
||||||
@ -1757,21 +1793,23 @@ async function loadReplenishIndex() {
|
|||||||
REPLENISH_PARAMS,
|
REPLENISH_PARAMS,
|
||||||
{ requestTimeout: 180000 }
|
{ requestTimeout: 180000 }
|
||||||
),
|
),
|
||||||
// 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条
|
// 实时库存(见文件头 ⑦-d):仓白名单多仓,**按 SKU 求和**(同一批货可能分散在多仓)。
|
||||||
|
// ⚠️ 不能再用「按 updateTime DESC 取首条」——那会只留一个仓的数,正好丢掉新仓。
|
||||||
cloudDatabase.query(
|
cloudDatabase.query(
|
||||||
`SELECT i.sku_id AS skuId, i.qty AS qty
|
`SELECT i.sku_id AS skuId, SUM(i.qty) AS qty
|
||||||
FROM dbo.jst_inventoryInfo i
|
FROM dbo.jst_inventoryInfo i
|
||||||
WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%'
|
WHERE i.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND i.sku_id LIKE N'MKH%'
|
||||||
ORDER BY i.sku_id ASC, i.updateTime DESC`,
|
GROUP BY i.sku_id`,
|
||||||
REPLENISH_PARAMS,
|
REPLENISH_PARAMS,
|
||||||
{ requestTimeout: 180000 }
|
{ requestTimeout: 180000 }
|
||||||
),
|
),
|
||||||
// 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到,
|
// 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到,
|
||||||
// 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。
|
// 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。
|
||||||
|
// 同样按仓白名单多仓求和(见 BRAND_WMS_CO_IDS 注释)。
|
||||||
cloudDatabase.query(
|
cloudDatabase.query(
|
||||||
`SELECT p.sku_id AS skuId, SUM(p.qty) AS qty
|
`SELECT p.sku_id AS skuId, SUM(p.qty) AS qty
|
||||||
FROM dbo.jst_packInventoryInfo p
|
FROM dbo.jst_packInventoryInfo p
|
||||||
WHERE p.wms_co_id = @brandWmsCoId AND p.sku_id LIKE N'MKH%'
|
WHERE p.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND p.sku_id LIKE N'MKH%'
|
||||||
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
|
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
|
||||||
GROUP BY p.sku_id`,
|
GROUP BY p.sku_id`,
|
||||||
REPLENISH_PARAMS,
|
REPLENISH_PARAMS,
|
||||||
@ -2682,6 +2720,9 @@ module.exports = {
|
|||||||
COMPONENT_STOCK_WAREHOUSE,
|
COMPONENT_STOCK_WAREHOUSE,
|
||||||
COMPONENT_STOCK_CHUNK_SIZE,
|
COMPONENT_STOCK_CHUNK_SIZE,
|
||||||
COMPONENT_SHORTAGE_MAX_LIST,
|
COMPONENT_SHORTAGE_MAX_LIST,
|
||||||
|
// ⑦-d 成品库存仓白名单(多仓求和口径)
|
||||||
|
BRAND_WMS_CO_IDS,
|
||||||
|
BRAND_WMS_CO_ID,
|
||||||
parseFlag,
|
parseFlag,
|
||||||
resolveScope,
|
resolveScope,
|
||||||
filterRows,
|
filterRows,
|
||||||
|
|||||||
@ -169,6 +169,15 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
|||||||
AND oi.sku_type = 'combine'
|
AND oi.sku_type = 'combine'
|
||||||
GROUP BY o.wms_co_id, ci.src_sku_id
|
GROUP BY o.wms_co_id, ci.src_sku_id
|
||||||
),
|
),
|
||||||
|
shipAgg AS (
|
||||||
|
-- 同一个 仓库+商品 可能同时命中多个来源分支(线上普通/线上组合装/全民客户普通/全民客户组合装),
|
||||||
|
-- 必须先按 仓库+商品 汇总成一行再 join,否则一个仓位行会被拆成多行、每行只带某分支的销量。
|
||||||
|
SELECT wmsCoId,
|
||||||
|
skuId,
|
||||||
|
SUM(CAST(salesQty AS DECIMAL(18, 2))) AS salesQty
|
||||||
|
FROM ship
|
||||||
|
GROUP BY wmsCoId, skuId
|
||||||
|
),
|
||||||
binAgg AS (
|
binAgg AS (
|
||||||
SELECT wmsCoId,
|
SELECT wmsCoId,
|
||||||
binName,
|
binName,
|
||||||
@ -192,7 +201,7 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
|||||||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||||||
JOIN binAgg a
|
JOIN binAgg a
|
||||||
ON a.wmsCoId = b.wmsCoId AND a.binName = b.binName AND a.binType = b.binType
|
ON a.wmsCoId = b.wmsCoId AND a.binName = b.binName AND a.binType = b.binType
|
||||||
LEFT JOIN ship s
|
LEFT JOIN shipAgg s
|
||||||
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
||||||
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId, a.skuCount, s.salesQty, sp.binCount
|
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId, a.skuCount, s.salesQty, sp.binCount
|
||||||
HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001
|
HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001
|
||||||
|
|||||||
@ -0,0 +1,318 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「仓储中心物流转运异常」每天定点推文本到钉钉群 —— 配置与状态存取
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 配置分两层存(与统一通知中心同一套做法,见 yydNotificationCenterSettings):
|
||||||
|
* · 普通字段 → 本地库 `system_config`,key = warehouse_logistics_exception_notify_config
|
||||||
|
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
|
||||||
|
*
|
||||||
|
* 通道是「钉钉群自定义机器人 webhook」(2026-10-22 业务定:不发 .xlsx 附件,
|
||||||
|
* 群里直接看文本),所以只有两个凭据字段:
|
||||||
|
* · webhookUrl 群机器人地址(含 access_token;完整回显,业务要能核对)
|
||||||
|
* · webhookSecret 加签密钥(SEC 开头,只回掩码;安全设置选「关键词/IP」时留空)
|
||||||
|
*
|
||||||
|
* 推送时刻用页面上的 HH:mm(默认 09:10,核验 09:00 之后),**按北京时间解释**:
|
||||||
|
* 生产容器是 UTC,绝不能拿容器本地时间比。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const database = require('../config/database');
|
||||||
|
const {
|
||||||
|
getResilientSecret,
|
||||||
|
saveResilientSecret,
|
||||||
|
} = require('./resilientSecretStore');
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'warehouse_logistics_exception_notify_config';
|
||||||
|
const SECRET_KEY = 'warehouse_logistics_exception_notify_webhook_secret';
|
||||||
|
const STATE_KEY = 'warehouse_logistics_exception_notify_state';
|
||||||
|
const DEFAULT_SEND_TIME = '09:10';
|
||||||
|
// 明细条数:text 消息上限 20000 字节,30 条中文明细约 9~12KB,留足余量
|
||||||
|
const DEFAULT_MAX_DETAIL_ROWS = 30;
|
||||||
|
const MAX_DETAIL_ROWS_LIMIT = 50;
|
||||||
|
// 页面地址(消息里给「查看全部」链接)。生产是带 8443 的域名,别写成裸域名
|
||||||
|
const DEFAULT_PAGE_URL = process.env.WLE_PAGE_URL
|
||||||
|
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-logistics-exception';
|
||||||
|
const CACHE_TTL_MS = 30 * 1000;
|
||||||
|
|
||||||
|
let cachedConfig = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
|
||||||
|
function normalizeText(value, maxLength = 512) {
|
||||||
|
return String(value ?? '').trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonParse(value, fallback = {}) {
|
||||||
|
if (value && typeof value === 'object') return value;
|
||||||
|
try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSecret(value) {
|
||||||
|
const text = normalizeText(value);
|
||||||
|
if (!text) return '';
|
||||||
|
if (text.length <= 8) return `${text.slice(0, 2)}******`;
|
||||||
|
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HH:mm(00:00 ~ 23:59);非法输入退回默认 09:10,不抛错(避免把定时任务配挂) */
|
||||||
|
function normalizeSendTime(value) {
|
||||||
|
const text = normalizeText(value, 5);
|
||||||
|
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
|
||||||
|
return DEFAULT_SEND_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */
|
||||||
|
function normalizeMaxDetailRows(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS;
|
||||||
|
return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePageUrl(value) {
|
||||||
|
const text = normalizeText(value, 1024);
|
||||||
|
if (!text) return DEFAULT_PAGE_URL;
|
||||||
|
try {
|
||||||
|
const url = new URL(text);
|
||||||
|
if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL;
|
||||||
|
return url.toString();
|
||||||
|
} catch (_error) {
|
||||||
|
return DEFAULT_PAGE_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoolean(value, fallback = false) {
|
||||||
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
const text = String(value).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSendTime(sendTime) {
|
||||||
|
const [hour, minute] = normalizeSendTime(sendTime).split(':').map((part) => Number(part));
|
||||||
|
return { hour, minute };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertConfigRow(value) {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||||
|
[CONFIG_KEY]
|
||||||
|
);
|
||||||
|
const text = JSON.stringify(value);
|
||||||
|
if (rows[0]) {
|
||||||
|
await database.local.query(
|
||||||
|
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||||
|
[text, 'json', CONFIG_KEY]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await database.local.insert('system_config', {
|
||||||
|
config_key: CONFIG_KEY,
|
||||||
|
config_value: text,
|
||||||
|
config_type: 'json',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readConfigRow() {
|
||||||
|
try {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||||
|
[CONFIG_KEY]
|
||||||
|
);
|
||||||
|
return safeJsonParse(rows[0]?.configValue, {});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WleNotify] 读取推送配置失败:', error.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
|
||||||
|
async function getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh = false } = {}) {
|
||||||
|
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig };
|
||||||
|
|
||||||
|
const stored = await readConfigRow();
|
||||||
|
const secret = await getResilientSecret(SECRET_KEY, {
|
||||||
|
legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)],
|
||||||
|
fallbackValues: [normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
|
||||||
|
});
|
||||||
|
|
||||||
|
cachedConfig = {
|
||||||
|
enabled: normalizeBoolean(stored.enabled, false),
|
||||||
|
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|
||||||
|
|| normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK, 1024),
|
||||||
|
webhookSecret: secret.value,
|
||||||
|
webhookSecretSource: secret.source,
|
||||||
|
sendTime: normalizeSendTime(stored.sendTime || stored.send_time),
|
||||||
|
maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows),
|
||||||
|
atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false),
|
||||||
|
pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url),
|
||||||
|
source: Object.keys(stored).length > 0 ? 'database' : 'default',
|
||||||
|
};
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return { ...cachedConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/
|
||||||
|
function missingFields(config = {}) {
|
||||||
|
const missing = [];
|
||||||
|
if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址');
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWarehouseLogisticsExceptionNotifyReady(config = {}) {
|
||||||
|
return missingFields(config).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAdminConfig(config = {}) {
|
||||||
|
return {
|
||||||
|
enabled: config.enabled === true,
|
||||||
|
webhookUrl: config.webhookUrl || '',
|
||||||
|
webhookSecretConfigured: Boolean(config.webhookSecret),
|
||||||
|
webhookSecretPreview: maskSecret(config.webhookSecret),
|
||||||
|
webhookSecretSource: config.webhookSecretSource || 'unconfigured',
|
||||||
|
sendTime: normalizeSendTime(config.sendTime),
|
||||||
|
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
|
||||||
|
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
|
||||||
|
atAll: config.atAll === true,
|
||||||
|
pageUrl: normalizePageUrl(config.pageUrl),
|
||||||
|
ready: isWarehouseLogisticsExceptionNotifyReady(config),
|
||||||
|
missingFields: missingFields(config),
|
||||||
|
source: config.source || 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWarehouseLogisticsExceptionNotifyAdminConfig() {
|
||||||
|
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
|
||||||
|
return toAdminConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
|
||||||
|
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
|
||||||
|
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
|
||||||
|
*/
|
||||||
|
async function saveWarehouseLogisticsExceptionNotifyConfig(input = {}) {
|
||||||
|
const current = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
|
||||||
|
const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512);
|
||||||
|
const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
enabled: normalizeBoolean(input.enabled, current.enabled),
|
||||||
|
webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl,
|
||||||
|
webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret),
|
||||||
|
sendTime: input.sendTime === undefined && input.send_time === undefined
|
||||||
|
? normalizeSendTime(current.sendTime)
|
||||||
|
: normalizeSendTime(input.sendTime ?? input.send_time),
|
||||||
|
maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined
|
||||||
|
? normalizeMaxDetailRows(current.maxDetailRows)
|
||||||
|
: normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows),
|
||||||
|
atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true),
|
||||||
|
pageUrl: input.pageUrl === undefined && input.page_url === undefined
|
||||||
|
? normalizePageUrl(current.pageUrl)
|
||||||
|
: normalizePageUrl(input.pageUrl ?? input.page_url),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.enabled) {
|
||||||
|
const missing = missingFields(config);
|
||||||
|
if (missing.length) {
|
||||||
|
throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.webhookUrl) {
|
||||||
|
// 这里只做「像不像」的浅校验;真正的连通性由「测试推送」按钮验证
|
||||||
|
const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender');
|
||||||
|
try {
|
||||||
|
config.webhookUrl = normalizeWebhookUrl(config.webhookUrl);
|
||||||
|
} catch (error) {
|
||||||
|
throw Object.assign(new Error(error.message), { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret);
|
||||||
|
await upsertConfigRow({
|
||||||
|
enabled: config.enabled,
|
||||||
|
webhookUrl: config.webhookUrl,
|
||||||
|
sendTime: config.sendTime,
|
||||||
|
maxDetailRows: config.maxDetailRows,
|
||||||
|
atAll: config.atAll,
|
||||||
|
pageUrl: config.pageUrl,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
cachedConfig = {
|
||||||
|
...config,
|
||||||
|
webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')),
|
||||||
|
source: 'database',
|
||||||
|
};
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return toAdminConfig(cachedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上次推送结果(页面展示 + 定时任务用来判断「今天是不是已经推过」) */
|
||||||
|
async function loadWarehouseLogisticsExceptionNotifyState() {
|
||||||
|
try {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||||
|
[STATE_KEY]
|
||||||
|
);
|
||||||
|
const state = safeJsonParse(rows[0]?.configValue, {});
|
||||||
|
return state && typeof state === 'object' ? state : {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WleNotify] 读取推送状态失败:', error.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWarehouseLogisticsExceptionNotifyState(next = {}) {
|
||||||
|
const value = JSON.stringify(next && typeof next === 'object' ? next : {});
|
||||||
|
try {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||||
|
[STATE_KEY]
|
||||||
|
);
|
||||||
|
if (rows[0]) {
|
||||||
|
await database.local.query(
|
||||||
|
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||||
|
[value, 'json', STATE_KEY]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await database.local.insert('system_config', {
|
||||||
|
config_key: STATE_KEY,
|
||||||
|
config_value: value,
|
||||||
|
config_type: 'json',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WleNotify] 保存推送状态失败:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNotifyConfigCache() {
|
||||||
|
cachedConfig = null;
|
||||||
|
cachedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CONFIG_KEY,
|
||||||
|
DEFAULT_MAX_DETAIL_ROWS,
|
||||||
|
DEFAULT_PAGE_URL,
|
||||||
|
DEFAULT_SEND_TIME,
|
||||||
|
MAX_DETAIL_ROWS_LIMIT,
|
||||||
|
SECRET_KEY,
|
||||||
|
STATE_KEY,
|
||||||
|
clearNotifyConfigCache,
|
||||||
|
getWarehouseLogisticsExceptionNotifyAdminConfig,
|
||||||
|
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
|
||||||
|
isWarehouseLogisticsExceptionNotifyReady,
|
||||||
|
loadWarehouseLogisticsExceptionNotifyState,
|
||||||
|
maskSecret,
|
||||||
|
missingFields,
|
||||||
|
normalizeMaxDetailRows,
|
||||||
|
normalizePageUrl,
|
||||||
|
normalizeSendTime,
|
||||||
|
saveWarehouseLogisticsExceptionNotifyConfig,
|
||||||
|
saveWarehouseLogisticsExceptionNotifyState,
|
||||||
|
splitSendTime,
|
||||||
|
toAdminConfig,
|
||||||
|
};
|
||||||
388
backend/src/services/warehouseLogisticsExceptionRegistry.js
Normal file
388
backend/src/services/warehouseLogisticsExceptionRegistry.js
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心物流转运异常(2026-10-21 新增)
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 需求(yanhui 2026-10-21):
|
||||||
|
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
|
||||||
|
* 下属 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: '信誉单' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
|
||||||
|
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} [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,
|
||||||
|
EXCEPTION_TYPES,
|
||||||
|
TYPE_MAP,
|
||||||
|
TRANSIT_INFO_KEYWORDS,
|
||||||
|
VERIFY_HOUR,
|
||||||
|
LOOKBACK_DAYS,
|
||||||
|
resolveCenter,
|
||||||
|
matchExcludedCarrier,
|
||||||
|
matchVoidedOrder,
|
||||||
|
matchNonRealOrder,
|
||||||
|
resolveBatch,
|
||||||
|
beijingNow,
|
||||||
|
formatDate,
|
||||||
|
parseDate,
|
||||||
|
addDays,
|
||||||
|
pad2,
|
||||||
|
};
|
||||||
493
backend/src/services/warehouseLogisticsExceptionService.js
Normal file
493
backend/src/services/warehouseLogisticsExceptionService.js
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
'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 || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {},
|
||||||
|
},
|
||||||
|
orders: [],
|
||||||
|
detailLimit: limit,
|
||||||
|
truncated: false,
|
||||||
|
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
|
||||||
|
generatedAt,
|
||||||
|
cached: false,
|
||||||
|
cacheAgeSec: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${batch.verifyDate}|${limit}`;
|
||||||
|
const cachedEntry = resultCache.get(cacheKey);
|
||||||
|
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
|
||||||
|
return {
|
||||||
|
...cachedEntry.data,
|
||||||
|
cached: true,
|
||||||
|
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await loadShippedPackages(batch);
|
||||||
|
|
||||||
|
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||||||
|
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: {} };
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sendAt = formatDateTime(row.send_time);
|
||||||
|
const logisticAt = formatDateTime(row.logistic_time);
|
||||||
|
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
|
||||||
|
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
|
||||||
|
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||||||
|
|
||||||
|
stat.exceptionOrders.add(oId);
|
||||||
|
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
orders: exceptions.slice(0, limit),
|
||||||
|
detailLimit: limit,
|
||||||
|
truncated: exceptions.length > limit,
|
||||||
|
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 payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用:返回**不分页**的异常明细(口径与页面完全一致,只是把明细上限放开)。
|
||||||
|
*
|
||||||
|
* 页面列表默认只取 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,
|
||||||
|
};
|
||||||
559
backend/src/services/warehouseShipTimelinessRegistry.js
Normal file
559
backend/src/services/warehouseShipTimelinessRegistry.js
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 需求(yanhui 2026-10-20):
|
||||||
|
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
||||||
|
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||||
|
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||||
|
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
||||||
|
* 今天 07:00 前审核的订单 → 13:00 核验;**截止今天 16:00 前审核**的单(含今天之前未发的)→ 18:00 核验;
|
||||||
|
* 昨天及更早审核、至今未发的(历史未发单)两个轮次都算。
|
||||||
|
* (2026-10-20 三次调整:
|
||||||
|
* 二次 —— 核验时刻由 12:00/16:00 改为 13:00/18:00;
|
||||||
|
* 三次 —— ① **窗口按「审核时间」判**(不是创建时间):业务指出的 9818476
|
||||||
|
* (创建 10-18、审核 10-20 13:06)这类下午才审的单,不能进 13:00 轮;
|
||||||
|
* ② **取消「满 24 小时才算超时」的门槛**:到核验时刻未发即超时;
|
||||||
|
* ③ **历史未发单一律算**,不卡「今天 00:00 起」。
|
||||||
|
* 业务原话:「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到
|
||||||
|
* 16 点审核的订单 18:00 核验;然后再看历史未发的订单」。)
|
||||||
|
* 展示口径:**只显示超时未发**(业务确认)。
|
||||||
|
*
|
||||||
|
* 业务确认的口径(2026-10-20):
|
||||||
|
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东自营仓/运营C专用仓 等)
|
||||||
|
* 例外:「京仓-横林二仓京东云仓」业务指定**排除**,不计入任何主仓(见下)
|
||||||
|
* ② 崔桥工厂半成品仓 —— 系统无同名仓,**接入「简尚-崔桥工厂半成品包装项目仓」**(15718196)
|
||||||
|
* ③ 展示结构 —— **只显示超时未发**
|
||||||
|
* ④ 明细列 —— 要同时给「内部单号」(o_id) 与「线上订单号」(so_id)
|
||||||
|
*
|
||||||
|
* ── 数据源 ────────────────────────────────────────────────────────────────
|
||||||
|
* 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头)
|
||||||
|
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||||
|
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||||
|
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
||||||
|
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
|
||||||
|
* ⚠️ 不是 `order_date`(平台下单时间);
|
||||||
|
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
||||||
|
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
||||||
|
* 实际下单在 10-13~10-18),拿它当创建时间会让所有单的时间被抹平;
|
||||||
|
* ⚠️ 也不是 `created/createTime/updateTime`(数仓 5 分钟批量灌库时间戳)。
|
||||||
|
* 待出库的单在出库单表里还没有记录 → 回退 `order_date` 并在接口里标注来源。
|
||||||
|
* · 已出库:销售出库单表能查到该 o_id(有 `io_date`),**或**订单 `send_date`
|
||||||
|
* 非空,**或**订单状态已是 `Sent` / `Delivering`。
|
||||||
|
* · 待出库:以上三条都不满足。
|
||||||
|
* · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓)
|
||||||
|
* ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属,
|
||||||
|
* 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。
|
||||||
|
*
|
||||||
|
* ── 仓库归属(按名称包含匹配,最长匹配优先)────────────────────────────────
|
||||||
|
* `rpt.jst_warehouseInfo` 实测(2026-10-20):
|
||||||
|
* 横林二仓 15033302 + 15301814/15835893/16040225/16132681
|
||||||
|
* ⚠️ 15197138「京仓-横林二仓京东云仓」**业务指定排除**(见 EXCLUDED_WAREHOUSE_KEYWORDS)
|
||||||
|
* 横林三仓 15043561
|
||||||
|
* 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**)
|
||||||
|
* 崔桥工厂仓 15038426 + 15301815/16040410
|
||||||
|
* 崔桥工厂品牌仓 15870495
|
||||||
|
* 崔桥工厂半成品包装项目仓 15718196(原名「简尚-崔桥工厂半成品包装项目仓」)
|
||||||
|
* 新苏北仓 15038347 + 15315031/15301812
|
||||||
|
* 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911
|
||||||
|
* 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076
|
||||||
|
* 山东品牌仓 15182705
|
||||||
|
* 河南洛宁仓 15539084
|
||||||
|
* ⟹ 仓库 ID → 主仓 的映射**在启动时从库表读取并缓存**(不用手写 ID 常量,
|
||||||
|
* 避免上游新增子仓后要改代码;新增子仓只要名字里带主仓关键词就会自动归入)。
|
||||||
|
* 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。
|
||||||
|
*
|
||||||
|
* ── 时效判定 ──────────────────────────────────────────────────────────────
|
||||||
|
* 两个时刻**必须区分**(2026-10-20 二次调整后最容易搞混的地方):
|
||||||
|
* `verifyHour` —— 几点核验(13:00 / 18:00)。判定「发了没发」「超时多久」都用它。
|
||||||
|
* `windowEndHour` —— 订单窗口上界(07:00 / 16:00)。只核验该时刻**前审核**的单。
|
||||||
|
* 取当前已生效且最晚的那一轮;未到 13:00 时无核验轮次:
|
||||||
|
* 13:00 → 核验「今天 00:00~07:00 审核」的订单 + 历史未发
|
||||||
|
* 18:00 → 核验「截止今天 16:00 前审核」的订单 + 今天之前审核仍未发的(历史未发)
|
||||||
|
* ⚠️ 不是「今天 00:00~16:00」这一个自然日切片,历史未发也在内;
|
||||||
|
* 山东两仓窗口上界 15:00、河南洛宁 13:00(核验时刻仍是 18:00)。
|
||||||
|
* 判定(2026-10-20 三次修正 —— **没有「满 24 小时」门槛**):
|
||||||
|
* · 超时未出库 = 到核验时刻仍未发货
|
||||||
|
* (出库单表查不到 + `send_time` 为空 + 状态不是 Sent/Delivering)
|
||||||
|
* 🔥 第七版错在加了「创建满 24h 才算超时」,导致 13:00 轮永远
|
||||||
|
* 看不到当天 7 点那批(才等了 6 小时),业务直接打回。
|
||||||
|
* · 已出库 = 出库单表有记录 / send_date 非空 / 状态 Sent、Delivering
|
||||||
|
* · 拆单父单(is_split=1)、被合并单(is_merge=1)**一律排除** ——
|
||||||
|
* 它们自己永不发货,货由子单/主单发出,算进来就是冤枉仓库。
|
||||||
|
* 业务只要「超时未出库」,所以列表只返回待出库的超时单。
|
||||||
|
*
|
||||||
|
* ── 时区 ──────────────────────────────────────────────────────────────────
|
||||||
|
* 订单时间列存的是**东八区本地时间**(实测 `order_date` 与 `created` 相差十几秒,
|
||||||
|
* 均为北京时间)。因此 SQL 里统一用 `CAST(GETDATE() AS date)` 取「今天」,
|
||||||
|
* 服务器与数据库都在东八区;**不要**在 Node 侧用 UTC 日期做判断。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
|
|
||||||
|
// ── 主仓定义(业务给定的 11 个)──────────────────────────────────────────
|
||||||
|
// keywords:该主仓在 rpt.jst_warehouseInfo.name 里应包含的关键词(任一命中即归属)
|
||||||
|
// 顺序即展示顺序;`match` 用最长关键词优先,所以「横林二仓品牌仓」不会被「横林二仓」抢走。
|
||||||
|
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: ['崔桥工厂品牌仓'] },
|
||||||
|
// 2026-10-20 二次调整:原先「崔桥工厂半成品仓」在仓库表里没有同名仓,只能留空占位;
|
||||||
|
// 业务指定用「简尚-崔桥工厂半成品包装项目仓」(wms_co_id=15718196, Id=198) 替代接入。
|
||||||
|
// ⚠️ 该 wms_co_id 同时也是配件仓(MKH 配件库存来源),但这里是**订单发货仓**维度,
|
||||||
|
// 统计的是「从该仓发出的订单」,与配件库存口径无关,不要混。
|
||||||
|
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
|
||||||
|
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
|
||||||
|
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
|
||||||
|
// 🔥 山东两个仓 18:00 轮的窗口更早(15:00 前,不是 16:00)——
|
||||||
|
// 业务 2026-10-20:「山东青岗集仓和山东品牌仓,这 2 个仓库时间不是 16:00 前,
|
||||||
|
// 是 15:00 前订单」。核验时刻仍是 18:00,只是卡单的范围窄一小时。
|
||||||
|
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'], windowEndHourByRound: { AFTERNOON: 15 } },
|
||||||
|
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'], windowEndHourByRound: { AFTERNOON: 15 } },
|
||||||
|
// 🔥 河南洛宁仓更早:18:00 轮只看 13:00 前创建的单(业务同一条指令)
|
||||||
|
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'], windowEndHourByRound: { AFTERNOON: 13 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 各主仓在某一轮的窗口上界小时。
|
||||||
|
// 返回 null 表示「用轮次默认值」(ROUNDS[].windowEndHour)。
|
||||||
|
// ⚠️ 13:00 轮没有例外,全部按 07:00;只有 18:00 轮有三处更早的上界。
|
||||||
|
function centerWindowEndHour(centerKey, roundKey) {
|
||||||
|
const center = CENTERS.find((c) => c.key === centerKey);
|
||||||
|
const override = center && center.windowEndHourByRound
|
||||||
|
? center.windowEndHourByRound[roundKey]
|
||||||
|
: undefined;
|
||||||
|
return Number.isFinite(override) ? override : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该主仓在这一轮的窗口上界('YYYY-MM-DD HH:mm:00'),没有例外就用轮次默认 */
|
||||||
|
function centerWindowEnd(centerKey, round) {
|
||||||
|
const hour = centerWindowEndHour(centerKey, round.key);
|
||||||
|
if (hour === null) return round.windowEnd;
|
||||||
|
return `${round.date} ${String(hour).padStart(2, '0')}:00:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段,
|
||||||
|
// 靠关键词长度倒序匹配即可(见 resolveCenter)。
|
||||||
|
const SORTED_KEYWORDS = CENTERS
|
||||||
|
.filter((c) => c.keywords.length)
|
||||||
|
.flatMap((c) => c.keywords.map((kw) => ({ kw, center: c })))
|
||||||
|
.sort((a, b) => b.kw.length - a.kw.length);
|
||||||
|
|
||||||
|
// ── 排除仓(业务指定,2026-10-20)──────────────────────────────────────────
|
||||||
|
// 「京仓-横林二仓京东云仓」(wms_co_id=15197138, Id=129) 虽然名字里带「横林二仓」,
|
||||||
|
// 但属于京东云仓代运营,**不纳入横林二仓的时效考核**(业务明确要求排除)。
|
||||||
|
// ⚠️ 用完整片段「横林二仓京东云仓」而不是「京东云仓」:
|
||||||
|
// 仓库表里还有「京仓-崔桥工厂京东云仓」(15283032)、
|
||||||
|
// 「京仓-山东青岗集京东云仓」(15283033),写短了会把它们一起排掉;
|
||||||
|
// 也不会误伤「简尚-横林二仓京东仓」(16040225) 与「京仓-横林二仓京东自营仓」(15835893)。
|
||||||
|
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze([
|
||||||
|
'横林二仓京东云仓',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 长关键词优先,避免以后加了「XX京东云仓」被短词抢先
|
||||||
|
const SORTED_EXCLUSIONS = [...EXCLUDED_WAREHOUSE_KEYWORDS].sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
|
// ── 时效轮次(业务给定,2026-10-20 二次调整)──────────────────────────────
|
||||||
|
// verifyHour :当天几点开始核验(13:00 / 18:00)
|
||||||
|
// windowEndHour :只核验「**审核时间**不晚于该时刻」的订单(07:00 / 16:00)
|
||||||
|
// ⚠️ 两者**不是同一个东西**,别再用一个字段兼着当:
|
||||||
|
// 旧实现拿 12:00 同时当核验时刻和窗口上界,导致「12:00 轮」实际把
|
||||||
|
// 07:00~12:00 创建的单也算进来了,与页面上写的「核验 07:00 前」不一致。
|
||||||
|
// ⚠️ 也不能在 windowEnd 之后再叠「满 N 小时」门槛 —— 13:00 轮里 07:00 审核的单
|
||||||
|
// 只等了 6 小时,加门槛就永远不出现在超时列表里(第七版被打回的原因)。
|
||||||
|
const ROUNDS = Object.freeze([
|
||||||
|
{ key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEndHour: 7 },
|
||||||
|
{ key: 'AFTERNOON', label: '18:00 轮', verifyHour: 18, windowEndHour: 16 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── 订单窗口下界(含历史的天数,**业务可调**)────────────────────────────
|
||||||
|
// 业务口径(2026-10-20 三次纠偏):「截止某时刻创建、至今仍未发货」—— 不按自然日切,
|
||||||
|
// 昨天、前天乃至更早挂着没发的单都要算进来(滚动累积的待发池)。
|
||||||
|
//
|
||||||
|
// ⚠️ 但不能无限往前:rpt 下全是视图、没有索引,扫得越远越慢。2026-10-20 实测
|
||||||
|
// (jst_Orders + jst_qmcust 并行,含已发单的窗口全量):
|
||||||
|
// 近 7 天 → 约 5s 近 30 天 → 约 45s
|
||||||
|
// 近 90 天 → 约 130s 近 365 天 → 约 500s(基本等于页面卡死)
|
||||||
|
// 所以默认取 30 天:覆盖「今日 + 近一个月累积未发」,兼顾口径与可用性。
|
||||||
|
//
|
||||||
|
// 同时实测 28 个目标仓的**全部未发单**:共 1,228 条,其中近 7 天 257、
|
||||||
|
// 近 30 天 496、近 90 天 792、更早 436(最早 2025-10-24)。
|
||||||
|
// ⟹ 30 天能看到约 40%;要看更久的老单,把下面数字改大,代价是首屏更慢。
|
||||||
|
const HISTORY_LOOKBACK_DAYS = 30;
|
||||||
|
|
||||||
|
// 只排除取消/合并单;Question 仍计入(与 mkhBomService 待发货口径一致)
|
||||||
|
//
|
||||||
|
// 🔥 Split(拆单):**必须排除**。拆单是「父单被拆成子单」的中间态,父单本身
|
||||||
|
// 不会发货(`send_date` 永远为空),子单才是真正进仓发货的那一单。
|
||||||
|
// 2026-10-20 实测:不加这个条件,一年前的 Split 父单全被算成「超时未发」,
|
||||||
|
// 全库未发 Split 单占比极高 —— 会让页面变成上万条的垃圾列表。
|
||||||
|
// ⚠️ 与 mkhBomService 的差异:那边是「预警今天要发的货」,Split 父单不带真实
|
||||||
|
// 发货需求,被算进去也无害;这边是「考核仓库时效」,误算就要背锅,必须排除。
|
||||||
|
const EXCLUDED_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split')`;
|
||||||
|
|
||||||
|
// ── 「已发货 / 待发货」状态口径(2026-10-20 四次修正,业务反馈数据错误)────
|
||||||
|
// 🔥 之前只排除了 Cancelled/Merged/Split,靠 `send_date IS NULL` 判「未发」,
|
||||||
|
// 实测**大量已发货的单被算成超时未发**(业务一眼就看出来了)。原因有三个:
|
||||||
|
// ① `send_date` 在部分店铺/平台不回写,订单状态已经是 Sent/Delivering,
|
||||||
|
// send_date 却还是 NULL(样本 9838341:Delivering,send_date 空);
|
||||||
|
// ② 拆单父单 `status` 不是 'Split' 而是 'Delivering'(父单随子单状态走),
|
||||||
|
// `is_split=1` 才是真标记(样本 9838537 就是这样);
|
||||||
|
// ③ 合并单 `is_merge=1` 同理,被并入主单,自己永不发货。
|
||||||
|
// 订单状态实测分布(2026-10-20,近 7 天,两表合计):
|
||||||
|
// Sent 61,833 / Cancelled 22,292 / Split 4,927 / Delivering 2,567 /
|
||||||
|
// Question 1,500 / Merged 682 / WaitOuterSent 30 / WaitConfirm 12
|
||||||
|
// ⟹ 判定「已发货」= status ∈ (Sent, Delivering) **或** send_date 非空
|
||||||
|
// **或** 销售出库单表能查到该 o_id(见 OUTBOUND_TABLES)
|
||||||
|
const SHIPPED_STATUS = Object.freeze(['Sent', 'Delivering']);
|
||||||
|
|
||||||
|
// 待出库候选:把「已发货」状态一并排除。判定顺序是
|
||||||
|
// ① SQL 先粗筛(下面这个条件 + send_date IS NULL + 非拆单/合并)
|
||||||
|
// ② Node 侧再查销售出库单表精确剔除(有出库记录 = 其实已出库)
|
||||||
|
// ③ Node 侧再把 Question(疑问单)分流到「业务挂起」,**不计超时**
|
||||||
|
// —— 注意 Question 必须留在候选里,否则统计不出挂起单数;
|
||||||
|
// 它只是不进 orders(超时明细),走 heldCount / heldOrders。
|
||||||
|
const PENDING_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split', N'Sent', N'Delivering')`;
|
||||||
|
|
||||||
|
// 拆单父单 / 被合并单:自己**永不发货**,货由子单或主单发出。
|
||||||
|
// 只排 status 不够(父单状态会跟着子单变成 Delivering),必须看标记位。
|
||||||
|
// ⚠️ is_split / is_merge 是 bit 且可能为 NULL —— 必须 ISNULL 兜底,
|
||||||
|
// 否则 `o.is_split = 0` 会把 NULL 行全部丢掉(实测会少掉大半候选)。
|
||||||
|
const NOT_SPLIT_MERGED_SQL = `ISNULL(o.is_split, 0) = 0 AND ISNULL(o.is_merge, 0) = 0`;
|
||||||
|
|
||||||
|
// ── 销售出库单表(聚水潭「销售出库单」页面的数据来源)──────────────────────
|
||||||
|
// 业务明确要求(2026-10-20):「已出库 / 待出库」与「创建时间」都以聚水潭
|
||||||
|
// **销售出库单**页面为准,不再用订单表的 send_date 猜。
|
||||||
|
//
|
||||||
|
// 两张表与订单表同构、店铺互斥:
|
||||||
|
// rpt.jst_salesOutboundInfo ← jst_Orders(京东/拼多多/抖音/得物/唯品会),103.7 万行
|
||||||
|
// rpt.jst_qmcust_salesoutbound ← jst_qmcust_orderlist(天猫/淘宝/1688),39.8 万行
|
||||||
|
// 字段口径(2026-10-20 实测确认):
|
||||||
|
// · `created` —— **出库单创建时间**(业务在聚水潭出库单页面看到的那个),
|
||||||
|
// 时点分布自然(8~23 点均匀,凌晨 4 点有一波补录),可用;
|
||||||
|
// · `io_date` —— **出库时间**(真正发货那一刻);
|
||||||
|
// · `modified` —— ⚠️ 会被数仓每天凌晨批量刷新(实测全被刷成 02:2x / 04:3x),
|
||||||
|
// 与订单表的 modified 一样**不可当作创建时间**;
|
||||||
|
// · **两张表都只含「已出库」的单**:103.7 万行里 io_date 为 0 条空值。
|
||||||
|
// ⟹ 「待出库」无法从出库单表直接查,只能靠「查不到 = 还没出库」反推。
|
||||||
|
const OUTBOUND_TABLES = Object.freeze({
|
||||||
|
jst_Orders: 'jst_salesOutboundInfo',
|
||||||
|
jst_qmcust_orderlist: 'jst_qmcust_salesoutbound',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 聚水潭「订单实时状态」表(2026-10-20 五次修正:按审核时间核算)──────────
|
||||||
|
// 🔥 前面三版的根因都在这里:`jst_Orders.status` / `jst_qmcust_orderlist.status`
|
||||||
|
// 是**下单那一刻的快照**,之后聚水潭侧取消、售后、审核都不会回写。
|
||||||
|
// 实测(2026-10-20):业务反馈「聚水潭查不到」的 6 单,订单表里
|
||||||
|
// status 全是 `WaitConfirm`,而在本表里真实状态是 **已取消**(发货前售后);
|
||||||
|
// 另有 1 单真实状态是 **异常**。上一版把它们当成「待出库超时」报了出来。
|
||||||
|
//
|
||||||
|
// `rpt.jst_order_express_details`(402 万行,query_time 准实时,最近一次今天 11:55)
|
||||||
|
// 是聚水潭订单状态的准实时镜像,字段语义(近 30 天实测覆盖率):
|
||||||
|
// · `order_status_name` —— 聚水潭真实订单状态
|
||||||
|
// 已发货 269,991(send_time 100%)|已取消 64,440|异常 6,669
|
||||||
|
// 发货中 2,448(first_oa 99.96%、send_time 0)|已支付待审核 833
|
||||||
|
// · `first_oa_confirm_time` —— **订单审核时间**(首次 OA 确认)= 业务要的起算点
|
||||||
|
// · `last_oa_confirm_time` —— 最后一次审核(重新审核会刷新)
|
||||||
|
// · `send_time` —— **发货时间**(已发货单 100% 有值,未发单必为空)
|
||||||
|
// · `warehouse_name` —— 发货仓名称(可直接套 CENTERS 关键词匹配)
|
||||||
|
// · `logistic_status_name` —— 物流状态(无物流单 / 未揽收 / 运输中 …)
|
||||||
|
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
|
||||||
|
const ORDER_STATE_TABLE = 'jst_order_express_details';
|
||||||
|
|
||||||
|
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
|
||||||
|
const SHIPPING_STATUS = '发货中';
|
||||||
|
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
|
||||||
|
const UNAUDITED_STATUS = '已支付待审核';
|
||||||
|
// 「异常」= 订单异常(缺货/地址/风控等),有审核时间的单列「异常待处理」,不计仓库超时
|
||||||
|
const ABNORMAL_STATUS = '异常';
|
||||||
|
// 不进考核也不展示的状态:已发货(已发)、已取消(业务侧取消/发货前售后)、
|
||||||
|
// 等供销商|外仓发货(不是本仓发)
|
||||||
|
// 🔥 「已取消」(业务口头叫**作废单**)必须排除(2026-10-20 业务定:「作废订单不用考核」):
|
||||||
|
// 这类单是客户付款后取消 / 发货前售后(标签带「发货前售后」),仓库根本不会发货。
|
||||||
|
// 2026-10-20 实测:近 30 天已取消 64,440 单,量级远大于真正该考核的「发货中」2,448 单,
|
||||||
|
// 漏掉这个排除,整个页面会被作废单淹没。
|
||||||
|
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
||||||
|
|
||||||
|
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
|
||||||
|
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
|
||||||
|
//
|
||||||
|
// ⚠️ 状态表 `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),
|
||||||
|
// 这才是聚水潭订单列表里「创建时间」那一列。
|
||||||
|
//
|
||||||
|
// 性能(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 实测最快(再大收益递减,再小批数太多)
|
||||||
|
const CREATED_BACKFILL_BATCH = 200;
|
||||||
|
|
||||||
|
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
|
||||||
|
// 业务 2026-10-20 反馈 so_id 5127276315127080023(内部单号 9486414)是作废单,
|
||||||
|
// 却出现在超时未发货里。四张表对照后的真相:
|
||||||
|
// · 状态表 jst_order_express_details:order_status_name=**发货中**、send_time 空
|
||||||
|
// ⟹ 只信状态表的话,这单就是「超时未发」—— 我们就是这么误判的;
|
||||||
|
// · 订单表 jst_qmcust_orderlist:status=**Merged**、send_date=2026-09-22 06:54:49;
|
||||||
|
// · 销售出库单:两张表**都有**出库记录(io_date 2026-09-22 10:59:55)。
|
||||||
|
// ⟹ 根因:状态表的 `send_time` 对**被合并单 / 拆单父单不回写**,
|
||||||
|
// 单子早随主单发走了,状态表还停在「发货中」。
|
||||||
|
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
||||||
|
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
||||||
|
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
||||||
|
|
||||||
|
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||||
|
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||||
|
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||||
|
const SHIP_TIMELINESS_HOURS = 24;
|
||||||
|
|
||||||
|
const WAREHOUSE_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||||
|
let warehouseCache = { at: 0, rows: [] };
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用「关键词最长匹配」把仓库名归到主仓。
|
||||||
|
*
|
||||||
|
* ⚠️ **先判排除、再判归属**:被排除的仓(如「京仓-横林二仓京东云仓」)
|
||||||
|
* 即使名字里带主仓关键词,也直接返回 null —— 它的订单不计入任何主仓。
|
||||||
|
*
|
||||||
|
* @returns {object|null} CENTERS 里的元素;无法归属或被排除返回 null
|
||||||
|
*/
|
||||||
|
function resolveCenter(warehouseName) {
|
||||||
|
if (!warehouseName) return null;
|
||||||
|
const name = String(warehouseName);
|
||||||
|
for (const kw of SORTED_EXCLUSIONS) {
|
||||||
|
if (name.includes(kw)) return null;
|
||||||
|
}
|
||||||
|
for (const item of SORTED_KEYWORDS) {
|
||||||
|
if (name.includes(item.kw)) return item.center;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取仓库表并建立「主仓 → wms_co_id 列表」映射。
|
||||||
|
* 缓存 1 小时;仓库表变更不频繁,且新增子仓会自动被关键词捞到。
|
||||||
|
*/
|
||||||
|
async function loadCenterWarehouseMap(force = false) {
|
||||||
|
const fresh = Date.now() - warehouseCache.at < WAREHOUSE_CACHE_TTL_MS;
|
||||||
|
if (!force && fresh && warehouseCache.rows.length) {
|
||||||
|
return warehouseCache.rows;
|
||||||
|
}
|
||||||
|
// ⚠️ cloudDatabase.query() 直接返回 recordset 数组,不是 {recordset} 对象
|
||||||
|
const rows = (await cloudDatabase.query(
|
||||||
|
`SELECT Id, name, wms_co_id
|
||||||
|
FROM dbo.jst_warehouseInfo`,
|
||||||
|
{},
|
||||||
|
{ requestTimeout: 60000 }
|
||||||
|
)) || [];
|
||||||
|
const byCenter = new Map(CENTERS.map((c) => [c.key, []]));
|
||||||
|
const unmatched = [];
|
||||||
|
const excluded = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = String(row.name || '');
|
||||||
|
// 被业务排除的仓单独记录,方便日后核对「是不是排多了/排少了」
|
||||||
|
if (SORTED_EXCLUSIONS.some((kw) => name.includes(kw))) {
|
||||||
|
excluded.push({ wmsCoId: Number(row.wms_co_id), name });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const center = resolveCenter(name);
|
||||||
|
if (center && byCenter.has(center.key)) {
|
||||||
|
byCenter.get(center.key).push({
|
||||||
|
wmsCoId: Number(row.wms_co_id),
|
||||||
|
warehouseId: Number(row.Id),
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unmatched.push({ wmsCoId: Number(row.wms_co_id), name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const list = CENTERS.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
placeholder: Boolean(c.placeholder),
|
||||||
|
warehouses: byCenter.get(c.key) || [],
|
||||||
|
}));
|
||||||
|
warehouseCache = {
|
||||||
|
at: Date.now(),
|
||||||
|
rows: list,
|
||||||
|
unmatchedCount: unmatched.length,
|
||||||
|
excluded,
|
||||||
|
};
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWarehouseCache() {
|
||||||
|
warehouseCache = { at: 0, rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算「当前已生效」的核验轮次。
|
||||||
|
*
|
||||||
|
* 13:00 之前 → **未到核验时间,返回 null**。
|
||||||
|
* 业务明确要求(2026-10-20 确认):未到核验时刻不做核验,
|
||||||
|
* 前端显示「未到核验时间」,**不要回填昨天的数据** ——
|
||||||
|
* 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。
|
||||||
|
* 13:00~17:59 → 用 13:00 轮(今天 00:00~07:00 **审核** + 历史未发)
|
||||||
|
* 18:00 之后 → 用 18:00 轮(截止 16:00 前**审核** + 今天之前未发的历史单)
|
||||||
|
*
|
||||||
|
* 🔥 「含历史」是 2026-10-20 业务第三次纠偏:
|
||||||
|
* 原实现把下界卡在「今天 00:00」,只统计当天新单,把昨天及更早挂着没发的
|
||||||
|
* 单全漏掉了。业务口径是「……然后再看历史未发的订单」——
|
||||||
|
* 这是一个滚动累积的待发池,不按自然日切。
|
||||||
|
*
|
||||||
|
* @param {Date} now 北京时间(默认 `beijingNow()`,容器是 UTC 时也安全)
|
||||||
|
* @returns {object|null} null 表示当前不在任何核验时段内
|
||||||
|
*/
|
||||||
|
function resolveActiveRound(now = beijingNow()) {
|
||||||
|
const hour = now.getHours();
|
||||||
|
const minute = now.getMinutes();
|
||||||
|
const minutes = hour * 60 + minute;
|
||||||
|
let active = null;
|
||||||
|
for (const round of ROUNDS) {
|
||||||
|
if (minutes >= round.verifyHour * 60) active = round;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13:00 前:首轮核验还没开始,不做判定
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = pad2(now.getMonth() + 1);
|
||||||
|
const d = pad2(now.getDate());
|
||||||
|
// 护栏起点(不是业务下界,见 HISTORY_LOOKBACK_DAYS)
|
||||||
|
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
|
||||||
|
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
|
||||||
|
return {
|
||||||
|
...active,
|
||||||
|
// deadline = 核验时刻:判「发了没发 / 超时多久」都用它
|
||||||
|
deadline: `${y}-${m}-${d} ${pad2(active.verifyHour)}:00:00`,
|
||||||
|
windowStart: floorStr,
|
||||||
|
// windowEnd = 订单窗口上界:SQL 的取数上界,**不是** deadline
|
||||||
|
windowEnd: `${y}-${m}-${d} ${pad2(active.windowEndHour)}:00:00`,
|
||||||
|
// 含历史未发单 —— 前端据此把「统计窗口 00:00~07:00」改成「截止 07:00 前(含历史)」
|
||||||
|
includeHistory: true,
|
||||||
|
date: `${y}-${m}-${d}`,
|
||||||
|
settled: false,
|
||||||
|
isPreviousDay: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
|
||||||
|
*/
|
||||||
|
function describeRounds(now = beijingNow()) {
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = pad2(now.getMonth() + 1);
|
||||||
|
const d = pad2(now.getDate());
|
||||||
|
const date = `${y}-${m}-${d}`;
|
||||||
|
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
|
||||||
|
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
|
||||||
|
return ROUNDS.map((r) => ({
|
||||||
|
key: r.key,
|
||||||
|
label: r.label,
|
||||||
|
date,
|
||||||
|
verifyHour: r.verifyHour,
|
||||||
|
deadline: `${date} ${pad2(r.verifyHour)}:00:00`,
|
||||||
|
windowStart: floorStr,
|
||||||
|
windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`,
|
||||||
|
includeHistory: true,
|
||||||
|
active: minutes >= r.verifyHour * 60,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下一轮的核验时刻。
|
||||||
|
*
|
||||||
|
* ⚠️ 2026-10-20 第九版起**不再使用**:轮次归属本身就是按审核时间卡的,
|
||||||
|
* 「审核晚于 13:00 的单」根本进不了 13:00 轮,会自然落到 18:00 轮,
|
||||||
|
* 不需要额外顺延。函数保留是因为「按下一轮核验时刻算」这个业务表述
|
||||||
|
* 未来若用于别的口径(例如按创建时间分轮),逻辑还是这一套。
|
||||||
|
*
|
||||||
|
* @param {object} round resolveActiveRound / describeRounds 的返回项(需带 date/key)
|
||||||
|
* @returns {string} 'YYYY-MM-DD HH:mm:00'
|
||||||
|
*/
|
||||||
|
function nextRoundDeadline(round) {
|
||||||
|
const idx = ROUNDS.findIndex((r) => r.key === round.key);
|
||||||
|
if (idx >= 0 && idx + 1 < ROUNDS.length) {
|
||||||
|
return `${round.date} ${pad2(ROUNDS[idx + 1].verifyHour)}:00:00`;
|
||||||
|
}
|
||||||
|
// 当天最后一轮 → 次日首轮
|
||||||
|
const [y, m, d] = String(round.date).split('-').map(Number);
|
||||||
|
const next = new Date(y, (m || 1) - 1, (d || 1) + 1);
|
||||||
|
return `${next.getFullYear()}-${pad2(next.getMonth() + 1)}-${pad2(next.getDate())}`
|
||||||
|
+ ` ${pad2(ROUNDS[0].verifyHour)}:00:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CENTERS,
|
||||||
|
ROUNDS,
|
||||||
|
HISTORY_LOOKBACK_DAYS,
|
||||||
|
EXCLUDED_WAREHOUSE_KEYWORDS,
|
||||||
|
resolveCenter,
|
||||||
|
centerWindowEndHour,
|
||||||
|
centerWindowEnd,
|
||||||
|
nextRoundDeadline,
|
||||||
|
loadCenterWarehouseMap,
|
||||||
|
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,
|
||||||
|
SHIPPING_STATUS,
|
||||||
|
UNAUDITED_STATUS,
|
||||||
|
ABNORMAL_STATUS,
|
||||||
|
IGNORED_STATES,
|
||||||
|
CREATED_SOURCE_TABLES,
|
||||||
|
CREATED_BACKFILL_BATCH,
|
||||||
|
VOID_ORDER_STATUS,
|
||||||
|
SHIP_TIMELINESS_HOURS,
|
||||||
|
toNumber,
|
||||||
|
};
|
||||||
775
backend/src/services/warehouseShipTimelinessService.js
Normal file
775
backend/src/services/warehouseShipTimelinessService.js
Normal file
@ -0,0 +1,775 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心 24 小时发货时效 —— 服务层
|
||||||
|
*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* 口径(2026-10-20 第九次修正,业务最终定版)
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* 业务原话(按时间顺序,最后一条为准):
|
||||||
|
* 17:12「当日 7 点前订单(7 点前订单**按照审核时间算**),13:00 核验未发就算超时」
|
||||||
|
* 17:5x「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 16 点审核的订单 18:00 核验;
|
||||||
|
* 然后再看历史未发的订单」
|
||||||
|
*
|
||||||
|
* ⟹ 三条铁律:
|
||||||
|
* ① **轮次归属看「审核时间」**(`last_oa_confirm_time`,聚水潭显示值):
|
||||||
|
* 今天 00:00~07:00 审核 → 13:00 轮
|
||||||
|
* **截止今天 16:00 前审核**的单 → 18:00 轮
|
||||||
|
* (业务 2026-10-20 补正:18:00 核验的是 16:00 前**所有**审核过的单,
|
||||||
|
* 不是「今天 00:00~16:00」这一个自然日切片 —— 含今天之前审核、
|
||||||
|
* 至今没发的历史未发单,也含 7 点前审核到 13:00 还没发出的;
|
||||||
|
* 山东两仓 15:00、河南洛宁 13:00 是仓级例外,见 registry)
|
||||||
|
* ⚠️ **不看创建时间**:业务亲自指出的 9818476(创建 10-18 12:49 / 审核 10-20 13:06)
|
||||||
|
* 和 9823980(创建 10-18 20:40 / 审核 10-20 09:15)都是下午/上午才审的,
|
||||||
|
* 按创建时间会被错算进 13:00 轮 —— 第八版就是这么错的。
|
||||||
|
* ② **历史未发的单一律算**:昨天及更早审核、至今没发货的,两个轮次都要看到
|
||||||
|
* (它们早就该发了,不该等今天的分段窗口)。
|
||||||
|
* ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。
|
||||||
|
* 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。
|
||||||
|
* ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充):
|
||||||
|
* 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」),
|
||||||
|
* 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。
|
||||||
|
*
|
||||||
|
* 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。
|
||||||
|
* 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)——
|
||||||
|
* 这就是聚水潭订单列表「审核时间」那一列显示的值,业务拿它对账。
|
||||||
|
* 之前取首次审核,业务抽查 3 单(5127368691073091434 等)立刻发现不一致:
|
||||||
|
* 首次 10-18 13:15 vs 聚水潭 10-20 13:38。**别再改成 first_oa**。
|
||||||
|
* 因为窗口也按审核时间卡,「审核 13:06 的单」只会进 18:00 轮,
|
||||||
|
* 已等自然用 18:00 算,不会出现「0 分」。
|
||||||
|
* 明细列表并列「审核时间 / 发货时间」—— 业务 2026-10-20 要求**去掉创建时间**,
|
||||||
|
* 改成看各订单的发货时间(未发的显示「未发货」),对账时直接和聚水潭发货时间比。
|
||||||
|
*
|
||||||
|
* 🔥 「已发 / 未发」还是以聚水潭「销售订单」的发货状态为准(= 状态表 send_time)。
|
||||||
|
* **不要**改用运单号(logistic_id)判已发:实测同一店铺里,
|
||||||
|
* 「已发货」的单独有运单号 + 未揽收,「发货中」的单也有运单号 + 未揽收 —— 判不出来。
|
||||||
|
* 运单号只用来在明细里标「已打单 · 未揽收」,给业务一个「仓库干到哪一步」的线索。
|
||||||
|
*
|
||||||
|
* 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行)
|
||||||
|
*
|
||||||
|
* 🔥 时区(2026-10-21 生产实测踩坑,别再裸用 `new Date()`):
|
||||||
|
* · 生产容器 `/etc/timezone = Etc/UTC`,而 MSSQL `GETDATE()` 返回**北京时间**,
|
||||||
|
* mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
|
||||||
|
* · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**,
|
||||||
|
* 轮次日期 / 核验窗口整体错位(实测 08:04 跑 13:00 轮得 3895 单,本地仅 6 单)。
|
||||||
|
* · 统一用 `registry.beijingNow()` 取「现在」(本机已是东八区时平移量为 0,行为不变)。
|
||||||
|
* **不要**去改容器 TZ —— 会波及全部既有定时任务。
|
||||||
|
*
|
||||||
|
* · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值;
|
||||||
|
* 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。
|
||||||
|
* · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created`
|
||||||
|
* 🔥 状态表里**没有**这一列,必须回订单表按 `o_id = inner_order_id` 补:
|
||||||
|
* · `createdAt`(状态表那个 datetime2)= 同步落库时间,晚 1.5~3.5h,**不能用**
|
||||||
|
* · `created` 才是聚水潭订单列表里的「创建时间」(比下单晚 15~30 秒)
|
||||||
|
* · 已发货 = `send_time` 非空(已发货单 100% 有值)
|
||||||
|
* · 待发货 = `send_time` 为空 且 `order_status_name = 发货中`
|
||||||
|
* · 仓库 = `warehouse_name`(直接套 CENTERS 关键词归属主仓)
|
||||||
|
*
|
||||||
|
* ── 为什么弃用 jst_Orders.status(前三版全栽在这)──────────────────────────
|
||||||
|
* 订单表的 `status` 是**下单那一刻的快照**,之后聚水潭侧取消、发货前售后、
|
||||||
|
* 审核通过都不会回写。实测(2026-10-20)业务反馈「聚水潭查不到」的 6 单:
|
||||||
|
* 订单表 status 全是 `WaitConfirm`(看起来像待发货),
|
||||||
|
* 状态表真实状态是 **已取消**(标签带「发货前售后」),另 1 单是 **异常**。
|
||||||
|
* ⟹ 所以「状态 / 是否已发」一律以状态表为准,**订单表只用来补创建时间**。
|
||||||
|
*
|
||||||
|
* 时间列语义(已逐一实测,别再猜):
|
||||||
|
* · `order_date` / `pay_date` —— 平台下单 / 付款时间(客户那边的时间)
|
||||||
|
* · `created`(订单表) —— ✅ **聚水潭创建时间**(展示用,比下单晚 15~30 秒)
|
||||||
|
* · `createTime`/`updateTime` —— 数仓灌库时间(带毫秒、批量、滞后约 5 分钟)
|
||||||
|
* · `modified` —— ⚠️ 最后修改时间,每天凌晨被批量刷新,不可用
|
||||||
|
* · `first_oa_confirm_time` —— 首次审单时间(审核时间缺失时的第二顺位回退)
|
||||||
|
* · `last_oa_confirm_time` —— ✅ **最新审核时间(本版考核基准)**
|
||||||
|
* · `send_time` —— **发货时间**
|
||||||
|
* · `query_time` —— 本表快照时间(判数据新鲜度)
|
||||||
|
*
|
||||||
|
* ── 三态分流(互斥)─────────────────────────────────────────────────────
|
||||||
|
* 超时未发货 = 发货中 + send_time 空 + 审核时间已过本轮窗口上界(07:00/16:00)
|
||||||
|
* → orders(考核仓库)
|
||||||
|
* 不计考核 = 已支付待审核(没审单)/ 异常(订单异常) → heldOrders
|
||||||
|
* 不展示 = 已发货 / 已取消(作废)/ 等供销商|外仓发货 → 直接丢弃
|
||||||
|
* 🔥 作废单(已取消)不考核:近 30 天 64,440 单,是「发货中」2,448 单的 26 倍,
|
||||||
|
* 不排除的话整页都是客户取消掉的单。
|
||||||
|
*
|
||||||
|
* ── 核验轮次(业务给定)──────────────────────────────────────────────────
|
||||||
|
* `verifyHour` —— 几点核验(13:00 / 18:00),判「超时多久」用它
|
||||||
|
* `windowEndHour` —— 只看该时刻**前审核**的单(07:00 / 16:00,仓级例外见 registry)
|
||||||
|
* ⚠️ 两者不是同一个东西,别合成一个字段
|
||||||
|
* ⚠️ 也**不要**再引入「满 N 小时才算超时」的门槛(第七版踩过,会被业务打回)
|
||||||
|
* ⚠️ 窗口**要**卡审核时间(第九版定版)。第六版曾因「卡审核时间 13:00 轮只剩 2 单」
|
||||||
|
* 退回创建时间,结果 9818476 / 9823980 这类下午才审的单被错算进 13:00 轮 →
|
||||||
|
* 业务亲自纠正:「7 点前订单**按照审核时间算**」。审核时间少是因为 13:00 轮本来就
|
||||||
|
* 只该有早上那批,不是数据不对。
|
||||||
|
*
|
||||||
|
* ── 性能(2026-10-20 实测,四个坑都踩过)──────────────────────────────────
|
||||||
|
* ① **不要 SELECT 宽列**。同一 WHERE 只取 2 列 0.7s,取 15 列(含
|
||||||
|
* `order_labels` / `buyer_message` / `remark`)**54s** —— 视图没法做
|
||||||
|
* 覆盖扫描,多取一列就是几万次基表回表。本表只投影 10 个窄列。
|
||||||
|
* ② **不要回订单表补金额**。`o_id IN (400 个)` 打两张订单宽视图,单条实测
|
||||||
|
* **38.7s**。发货时效不需要金额,直接不做。
|
||||||
|
* (但补 `created` **可以**:只取两列时 9,676 个候选只要 14.5s + 2.9s。)
|
||||||
|
* ③ **不要额外查新鲜度**。`MAX(send_time)` 又是一次 402 万行扫描(21.1s)。
|
||||||
|
* 快照时间直接从主查询的 `query_time` 在 Node 里取 MAX。
|
||||||
|
* ④ `SELECT *` 更是灾难 —— 单 form 单号查一次 **5 分钟**不返回。
|
||||||
|
* ⑤ 走 `querySlow`,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。
|
||||||
|
*
|
||||||
|
* ── 时区 ─────────────────────────────────────────────────────────────────
|
||||||
|
* 所有时间列都是**东八区本地时间**;服务与库都在东八区。
|
||||||
|
* **不要**在 Node 侧用 UTC 日期判断。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
|
const registry = require('./warehouseShipTimelinessRegistry');
|
||||||
|
|
||||||
|
const {
|
||||||
|
ORDER_STATE_TABLE,
|
||||||
|
SHIPPING_STATUS,
|
||||||
|
UNAUDITED_STATUS,
|
||||||
|
ABNORMAL_STATUS,
|
||||||
|
IGNORED_STATES,
|
||||||
|
CREATED_SOURCE_TABLES,
|
||||||
|
CREATED_BACKFILL_BATCH,
|
||||||
|
VOID_ORDER_STATUS,
|
||||||
|
resolveCenter,
|
||||||
|
} = registry;
|
||||||
|
|
||||||
|
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
|
||||||
|
const DEFAULT_DETAIL_LIMIT = 500;
|
||||||
|
const MAX_DETAIL_LIMIT = 2000;
|
||||||
|
|
||||||
|
// 状态表快照超过这个分钟数没刷新 → 页面提示「数据滞后」。
|
||||||
|
// 正常 ETL 每小时至少刷一轮(每小时约 1000 行有变化),90 分钟足够容错。
|
||||||
|
const STATE_STALE_MINUTES = 90;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不参与发货时效考核的快递(业务 2026-10-20 指定):
|
||||||
|
* 自提 —— 客户自己来拉,仓库不负责派送
|
||||||
|
* 中铁 —— 中铁物流(大件专线)
|
||||||
|
* 壹米滴答 —— 专线物流(大件)
|
||||||
|
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
|
||||||
|
* 只影响「超时未发」判定,不影响「已发」判定。
|
||||||
|
*/
|
||||||
|
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。
|
||||||
|
*
|
||||||
|
* 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」——
|
||||||
|
* 销售订单页面显示「已发货」= status `Sent`。`send_date` 很多店铺不回写
|
||||||
|
* (实测 103 单里 103 单都是空),所以必须把 status 也当依据。
|
||||||
|
* ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」,
|
||||||
|
* 正是本页要考核的对象;算进来整页就空了。
|
||||||
|
*/
|
||||||
|
const ORDER_SHIPPED_STATUS = ['Sent'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结果缓存。
|
||||||
|
*
|
||||||
|
* 这张视图单次查询实测 10~60 秒(取决于缓存温度与库负载),页面又是
|
||||||
|
* `AUTO_REFRESH_MS` 定时轮询 —— 不缓存的话每轮轮询都会重压一次库,
|
||||||
|
* 页面也会一直转圈。缓存 key 含「轮次 + 日期 + limit」,
|
||||||
|
* 业务点「刷新」时前端带 `force=1` 绕过缓存强制重查。
|
||||||
|
*/
|
||||||
|
const CACHE_TTL_MS = Number(process.env.CANGKU_WST_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||||
|
let resultCache = { key: '', at: 0, data: null };
|
||||||
|
|
||||||
|
/** 清缓存(切轮次 / 手工排查时用) */
|
||||||
|
function clearShipTimelinessCache() {
|
||||||
|
resultCache = { key: '', at: 0, data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 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);任一为空返回 0 */
|
||||||
|
function diffMinutes(fromText, toText) {
|
||||||
|
const a = formatDateTime(fromText);
|
||||||
|
const b = formatDateTime(toText);
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
const pa = new Date(a.replace(' ', 'T')).getTime();
|
||||||
|
const pb = new Date(b.replace(' ', 'T')).getTime();
|
||||||
|
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return 0;
|
||||||
|
return Math.floor((pb - pa) / 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「聚水潭订单状态」里所有未发货的单(发货中 / 待审核 / 异常)。
|
||||||
|
*
|
||||||
|
* 为什么不用 wms_co_id 过滤:本表只有 `warehouse_name`,没有仓 ID。
|
||||||
|
* 近 30 天三类合计约 1 万行,一次取回在 Node 侧按仓库名归属主仓,代价可接受。
|
||||||
|
*
|
||||||
|
* ⚠️ 只投影 11 个**窄列**:`order_labels`/`buyer_message`/`remark` 这类长文本
|
||||||
|
* 会把 0.7s 的查询拖到 54s(视图无法覆盖扫描,每行都要回基表)。
|
||||||
|
* ⚠️ `send_time IS NULL` 是关键:已发货单在这里 100% 有值。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-20 第八版:**不再把「创建 ≤ 核验−24h」下推到 SQL**。
|
||||||
|
* 本版口径是「到核验时刻未发即超时」,`send_time IS NULL` 就是候选;
|
||||||
|
* 窗口上界改在 Node 侧按**审核时间**判。SQL 里那两个时间列只是用来
|
||||||
|
* 判断窗口归属,不再参与「等了多少小时」的门槛。
|
||||||
|
*/
|
||||||
|
async function loadPendingStates(round) {
|
||||||
|
const rows = await cloudDatabase.querySlow(
|
||||||
|
// 🔥 时间列直接取原始 datetime,**不要**在 SQL 里 CONVERT 成字符串:
|
||||||
|
// CONVERT 会作用到扫描到的每一行(谓词下推不到视图里),实测把点查拖到 40s+;
|
||||||
|
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
|
||||||
|
// 就是库里存的北京时间,不会偏移 8 小时。
|
||||||
|
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
||||||
|
e.order_status_name, e.logistic_status_name,
|
||||||
|
e.logistic_id, e.logistic_company_name,
|
||||||
|
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
|
||||||
|
e.send_time, e.query_time
|
||||||
|
FROM dbo.${ORDER_STATE_TABLE} e
|
||||||
|
WHERE e.send_time IS NULL
|
||||||
|
AND e.pay_time >= @windowStart
|
||||||
|
AND e.order_status_name IN (@shipStatus, @st1, @st2)`,
|
||||||
|
{
|
||||||
|
shipStatus: SHIPPING_STATUS,
|
||||||
|
st1: UNAUDITED_STATUS,
|
||||||
|
st2: ABNORMAL_STATUS,
|
||||||
|
windowStart: round.windowStart,
|
||||||
|
},
|
||||||
|
// 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s)
|
||||||
|
{ connectRequestTimeoutMs: 300000 }
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 并发跑任务,最多 `limit` 个同时在飞(不把连接池打满) */
|
||||||
|
async function mapLimit(items, limit, worker) {
|
||||||
|
const out = [];
|
||||||
|
let cursor = 0;
|
||||||
|
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
for (;;) {
|
||||||
|
const i = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
if (i >= items.length) return;
|
||||||
|
out[i] = await worker(items[i], i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回订单表补「起算点 + 作废事实」。
|
||||||
|
*
|
||||||
|
* 状态表 `jst_order_express_details` 里没有聚水潭创建时间(`createdAt` 是同步落库
|
||||||
|
* 时间,比付款晚 1.5~3.5 小时),只能回两张订单表按 `o_id = inner_order_id` 取
|
||||||
|
* `created`(稳定比下单晚 15~30 秒,即聚水潭订单列表里的「创建时间」)。
|
||||||
|
*
|
||||||
|
* 🔥 同一趟把**作废事实**也取回来(业务 2026-10-20 反馈作废单被算成超时):
|
||||||
|
* 状态表的 `send_time` 对**被合并单 / 拆单父单不回写** —— 单子早随主单发走了,
|
||||||
|
* 状态表却还是「发货中 + send_time 空」。所以必须再看订单表的
|
||||||
|
* `status` / `send_date` / `is_merge` / `is_split`(见 registry.VOID_ORDER_STATUS)。
|
||||||
|
*
|
||||||
|
* ⚠️ 只投影这 6 个**窄列**。实测 9,676 个候选:窄列 14.5s + 2.9s,
|
||||||
|
* 一旦连 pay_amount / labels 一起取,单批 400 个就要 38.7s。
|
||||||
|
*
|
||||||
|
* @param {number[]} oids 内部单号
|
||||||
|
* @returns {Promise<Map<number, {createdAt:string, status:string, sendDate:string, void:boolean, shipped:boolean}>>}
|
||||||
|
*/
|
||||||
|
async function backfillOrderFacts(oids) {
|
||||||
|
const map = new Map();
|
||||||
|
if (!oids.length) return map;
|
||||||
|
|
||||||
|
let remaining = oids;
|
||||||
|
for (const table of CREATED_SOURCE_TABLES) {
|
||||||
|
if (!remaining.length) break;
|
||||||
|
const batches = [];
|
||||||
|
for (let i = 0; i < remaining.length; i += CREATED_BACKFILL_BATCH) {
|
||||||
|
batches.push(remaining.slice(i, i + CREATED_BACKFILL_BATCH));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await mapLimit(batches, 4, async (batch) => {
|
||||||
|
try {
|
||||||
|
return await cloudDatabase.querySlow(
|
||||||
|
`SELECT o_id, created, status, send_date, is_merge, is_split
|
||||||
|
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
|
||||||
|
{},
|
||||||
|
{ connectRequestTimeoutMs: 300000 }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 补不到不致命:调用方会回退到付款时间,不整页失败
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rows of results) {
|
||||||
|
for (const r of rows || []) {
|
||||||
|
const id = Number(r.o_id);
|
||||||
|
if (!Number.isFinite(id)) continue;
|
||||||
|
const status = String(r.status || '');
|
||||||
|
const sendDate = formatDateTime(r.send_date);
|
||||||
|
map.set(id, {
|
||||||
|
createdAt: formatDateTime(r.created),
|
||||||
|
status,
|
||||||
|
sendDate,
|
||||||
|
// 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货
|
||||||
|
void: VOID_ORDER_STATUS.includes(status)
|
||||||
|
|| Number(r.is_merge) === 1
|
||||||
|
|| Number(r.is_split) === 1,
|
||||||
|
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
|
||||||
|
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
|
||||||
|
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining = remaining.filter((n) => !map.has(n));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySummary() {
|
||||||
|
return {
|
||||||
|
// 超时未发货(考核仓库的部分)
|
||||||
|
overdueUnshipped: 0,
|
||||||
|
overdueCenterCount: 0,
|
||||||
|
oldestDays: 0,
|
||||||
|
// 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任
|
||||||
|
heldCount: 0,
|
||||||
|
// 已剔除的作废 / 已发单(页面只作说明,不进考核)
|
||||||
|
voidCount: 0,
|
||||||
|
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答)
|
||||||
|
carrierSkipped: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmptyCenters(centerList) {
|
||||||
|
return centerList.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
placeholder: c.placeholder,
|
||||||
|
warehouseCount: c.warehouses.length,
|
||||||
|
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
|
||||||
|
overdueUnshipped: 0,
|
||||||
|
oldestDays: 0,
|
||||||
|
heldCount: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getShipTimeliness(options = {}) {
|
||||||
|
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
|
||||||
|
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
|
||||||
|
const now = options.now instanceof Date ? options.now : registry.beijingNow();
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||||
|
MAX_DETAIL_LIMIT
|
||||||
|
);
|
||||||
|
|
||||||
|
// ① 主仓清单(用于展示「含子仓 N 个」;归属判定走 warehouse_name 关键词)
|
||||||
|
const centerList = await registry.loadCenterWarehouseMap();
|
||||||
|
const pad2 = (n) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// ② 决定本轮截止时刻
|
||||||
|
const rounds = registry.describeRounds(now);
|
||||||
|
let round = null;
|
||||||
|
if (options.round) {
|
||||||
|
const target = registry.ROUNDS.find((r) => r.key === String(options.round).toUpperCase());
|
||||||
|
if (!target) {
|
||||||
|
const err = new Error(`未知的核验轮次: ${options.round}`);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
round = rounds.find((r) => r.key === target.key);
|
||||||
|
if (!round) {
|
||||||
|
const err = new Error(`无法构造核验窗口: ${options.round}`);
|
||||||
|
err.statusCode = 500;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
round = registry.resolveActiveRound(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 13:00 前没有轮次生效(业务口径):直接返回「未到核验时间」,不查库
|
||||||
|
if (!round) {
|
||||||
|
const nextRound = rounds.find((r) => !r.active) || rounds[0];
|
||||||
|
return {
|
||||||
|
generatedAt: formatDateTime(now),
|
||||||
|
date: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`,
|
||||||
|
round: null,
|
||||||
|
notStarted: true,
|
||||||
|
isPreviousDay: false,
|
||||||
|
rounds,
|
||||||
|
nextRoundAt: nextRound ? nextRound.deadline : '',
|
||||||
|
nextWindowEnd: nextRound ? nextRound.windowEnd : '',
|
||||||
|
deadline: '',
|
||||||
|
window: null,
|
||||||
|
summary: emptySummary(),
|
||||||
|
centers: buildEmptyCenters(centerList),
|
||||||
|
orders: [],
|
||||||
|
heldOrders: [],
|
||||||
|
truncated: false,
|
||||||
|
detailLimit: limit,
|
||||||
|
detailDegraded: false,
|
||||||
|
freshness: null,
|
||||||
|
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时
|
||||||
|
// 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用
|
||||||
|
const force = options.force === true
|
||||||
|
|| ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase());
|
||||||
|
const cacheKey = `${round.key}|${round.date}|${limit}`;
|
||||||
|
if (!force && resultCache.data && resultCache.key === cacheKey
|
||||||
|
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
|
||||||
|
return {
|
||||||
|
...resultCache.data,
|
||||||
|
cached: true,
|
||||||
|
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let stateRows = [];
|
||||||
|
try {
|
||||||
|
stateRows = await loadPendingStates(round);
|
||||||
|
} catch (e) {
|
||||||
|
const err = new Error(`聚水潭订单状态表查询失败:${e.message}`);
|
||||||
|
err.statusCode = 502;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③-b 补起算点「聚水潭创建时间」+ 作废事实(状态表两样都没有,回订单表取)
|
||||||
|
const oids = [...new Set(
|
||||||
|
stateRows.map((r) => Number(r.inner_order_id)).filter((n) => Number.isFinite(n))
|
||||||
|
)];
|
||||||
|
const orderFacts = await backfillOrderFacts(oids);
|
||||||
|
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
||||||
|
let voidCount = 0;
|
||||||
|
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账
|
||||||
|
let carrierSkipped = 0;
|
||||||
|
|
||||||
|
// ④ 分桶
|
||||||
|
const acc = new Map(centerList.map((c) => [c.key, {
|
||||||
|
pendingCount: 0,
|
||||||
|
oldestDays: 0,
|
||||||
|
heldCount: 0,
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const pendingRows = [];
|
||||||
|
const heldRows = [];
|
||||||
|
const seenSo = new Set();
|
||||||
|
let snapshotAt = '';
|
||||||
|
|
||||||
|
for (const row of stateRows) {
|
||||||
|
// 快照时间:状态表最后一次刷新(顺带算,不再单独扫一遍 402 万行表)
|
||||||
|
const qt = formatDateTime(row.query_time);
|
||||||
|
if (qt > snapshotAt) snapshotAt = qt;
|
||||||
|
|
||||||
|
const center = resolveCenter(row.warehouse_name);
|
||||||
|
if (!center) continue; // 非 11 主仓(代发仓/未指定仓/POP 仓…)
|
||||||
|
const bucket = acc.get(center.key);
|
||||||
|
if (!bucket) continue;
|
||||||
|
|
||||||
|
const state = String(row.order_status_name || '');
|
||||||
|
// 🔥 作废单不考核 —— 业务 2026-10-20 明确「作废订单不用考核」。
|
||||||
|
// 状态表的 send_time 对**被合并单 / 拆单父单不回写**(案例 5127276315127080023:
|
||||||
|
// 状态表「发货中 + send_time 空」,订单表却是 Merged 且 send_date 有值、
|
||||||
|
// 出库单也早就生成了),所以必须回订单表再判一次,光信状态表会被冤枉。
|
||||||
|
const fact = orderFacts.get(Number(row.inner_order_id));
|
||||||
|
if (fact && (fact.void || fact.shipped)) {
|
||||||
|
voidCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉
|
||||||
|
if (IGNORED_STATES.includes(state)) continue;
|
||||||
|
|
||||||
|
// 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 ——
|
||||||
|
// 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
|
||||||
|
// 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。
|
||||||
|
// 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。
|
||||||
|
const carrier = String(row.logistic_company_name || '').trim();
|
||||||
|
const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k));
|
||||||
|
if (hitCarrier) {
|
||||||
|
carrierSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 窗口归属 = **审核时间**(业务 2026-10-20 最终定版,两次纠偏后的结论)────
|
||||||
|
// 业务原话:「当日 7 点前订单(7 点前订单**按照审核时间算**)……」
|
||||||
|
// 实测案例(业务亲自指出的两单):
|
||||||
|
// · 9818476:创建 10-18 12:49,但**审核 10-20 13:06** → 不是 7 点前的单,13:00 轮不能出现
|
||||||
|
// · 9823980:创建 10-18 20:40,**审核 10-20 09:15** → 同理,归 18:00 轮
|
||||||
|
// ⟹ 「7 点前/16 点前」卡的是**审核时间**:审核 ≤ 07:00 归 13:00 轮,
|
||||||
|
// 审核 ≤ 16:00 归 18:00 轮(山东两仓 15:00、河南洛宁 13:00,见 registry)。
|
||||||
|
// ⚠️ 别看创建时间:客户凌晨下单、仓库上午才审的单,按创建时间会提前算超时。
|
||||||
|
// ⚠️ 审核时间取**最后一次**(last_oa),见下面 458 行开始的说明。
|
||||||
|
const createdAt = fact ? fact.createdAt : '';
|
||||||
|
const payAt = formatDateTime(row.pay_time);
|
||||||
|
const auditFirst = formatDateTime(row.first_oa_confirm_time);
|
||||||
|
const auditLast = formatDateTime(row.last_oa_confirm_time);
|
||||||
|
// 🔥 取**最后一次**审核(= 聚水潭订单列表里「审核时间」那一列显示的值)。
|
||||||
|
// 业务 2026-10-20 20:0x 抽查 3 单指出「审核时间跟系统里的不一致」,
|
||||||
|
// 根因就是我们之前取错了列(first_oa = 首次审核):
|
||||||
|
// 5127368691073091434 首次 10-18 13:15 / 聚水潭显示 10-20 13:38
|
||||||
|
// 261019-670795046400872 首次 10-19 08:48 / 聚水潭显示 10-20 09:31
|
||||||
|
// 261019-558387771763179 首次 10-19 11:21 / 聚水潭显示 10-20 09:11
|
||||||
|
// ⟹ 页面必须跟聚水潭对齐,否则业务一对账就说「跑错了」。
|
||||||
|
const auditAt = auditLast || auditFirst;
|
||||||
|
const windowAt = auditAt || createdAt || payAt;
|
||||||
|
const benchmarkAt = windowAt;
|
||||||
|
const benchmarkSource = auditAt
|
||||||
|
? (auditLast ? 'last_audit' : 'first_audit')
|
||||||
|
: (createdAt ? 'created' : 'pay');
|
||||||
|
|
||||||
|
// 同一 so_id 可能因拆包出现多行(实测有),按 so_id + 窗口时间 去重
|
||||||
|
const dedupeKey = `${row.so_id}|${windowAt}`;
|
||||||
|
if (seenSo.has(dedupeKey)) continue;
|
||||||
|
seenSo.add(dedupeKey);
|
||||||
|
|
||||||
|
// ── 本轮核验范围(业务 2026-10-20 最终定版)────────────────────────────
|
||||||
|
// ① **今天的单按审核时间分段**:
|
||||||
|
// 今天 00:00 ~ 07:00 审核 → 13:00 轮核验
|
||||||
|
// **截止今天 16:00 前审核**的单 → 18:00 轮核验
|
||||||
|
// (= 16:00 前所有审核过的单,含 7 点前没发出的;不是「07:00~16:00」那一段。
|
||||||
|
// 山东两仓 15:00、河南洛宁 13:00 为仓级例外)
|
||||||
|
// ⟹ 今天 09:15 审核的单(9823980)、13:06 审核的单(9818476)
|
||||||
|
// **不进 13:00 轮**,只在 18:00 轮出现(业务亲自指出的两单)
|
||||||
|
// ② **历史未发的单一律算**(昨天及更早审核、至今没发货)—— 它们已经超时,
|
||||||
|
// 两个轮次都该看到,不受「今天 00:00 起」的限制。
|
||||||
|
// ③ 30 天护栏只为查询性能,不是业务边界。
|
||||||
|
const windowEndAt = registry.centerWindowEnd(center.key, round);
|
||||||
|
const todayStart = `${round.date} 00:00:00`;
|
||||||
|
if (windowAt && windowAt < round.windowStart) continue; // 超出 30 天护栏
|
||||||
|
if (windowAt && windowAt >= todayStart && windowAt > windowEndAt) continue;
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
row,
|
||||||
|
center,
|
||||||
|
state,
|
||||||
|
createdAt,
|
||||||
|
payAt,
|
||||||
|
auditAt,
|
||||||
|
windowAt,
|
||||||
|
benchmarkAt,
|
||||||
|
benchmarkSource,
|
||||||
|
warehouseName: String(row.warehouse_name || ''),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 不计考核:没审单(已支付待审核)或订单异常,都不该算仓库超时 ──────
|
||||||
|
if (state === UNAUDITED_STATUS || state === ABNORMAL_STATUS) {
|
||||||
|
bucket.heldCount += 1;
|
||||||
|
heldRows.push({
|
||||||
|
...base,
|
||||||
|
holdReason: state === UNAUDITED_STATUS ? '待审核' : '订单异常',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
|
||||||
|
|
||||||
|
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
|
||||||
|
if (!benchmarkAt) {
|
||||||
|
bucket.heldCount += 1;
|
||||||
|
heldRows.push({ ...base, holdReason: '缺审核时间' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// waited = 审核时间 → 本轮核验时刻(13:00 / 18:00)
|
||||||
|
// ⚠️ 因为窗口本身按审核时间卡(审核 ≤ 07:00 / 16:00),benchmark 必然 ≤ deadline,
|
||||||
|
// 这里不需要再处理「审核晚于核验」的情况 —— 那种单会自然落到下一轮:
|
||||||
|
// 审核 13:06 的单进不了 13:00 轮(07:00 上界),只在 18:00 轮出现,已等 = 18:00 − 13:06。
|
||||||
|
const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline));
|
||||||
|
|
||||||
|
bucket.pendingCount += 1;
|
||||||
|
const days = Math.floor(waited / 1440);
|
||||||
|
if (days > bucket.oldestDays) bucket.oldestDays = days;
|
||||||
|
pendingRows.push({ ...base, waited });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ 排序:最久的排前面(业务一眼看最急的)
|
||||||
|
pendingRows.sort((a, b) => {
|
||||||
|
if (a.windowAt < b.windowAt) return -1;
|
||||||
|
if (a.windowAt > b.windowAt) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
heldRows.sort((a, b) => {
|
||||||
|
const x = a.windowAt || '';
|
||||||
|
const y = b.windowAt || '';
|
||||||
|
if (x < y) return -1;
|
||||||
|
if (x > y) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailRows = pendingRows.slice(0, limit);
|
||||||
|
const heldDetail = heldRows.slice(0, limit);
|
||||||
|
|
||||||
|
const orders = detailRows.map((x) => {
|
||||||
|
const r = x.row;
|
||||||
|
return {
|
||||||
|
oId: String(r.inner_order_id || ''),
|
||||||
|
soId: String(r.so_id || ''),
|
||||||
|
shopName: String(r.shop_name || ''),
|
||||||
|
// 创建时间 = 聚水潭订单创建时间(业务 2026-10-20 已从明细列去掉,仅保留字段做回退基准)
|
||||||
|
createdAt: x.createdAt,
|
||||||
|
// 发货时间:本列表只取 send_time IS NULL 的单,所以这里恒为空 → 前端显示「未发货」
|
||||||
|
sendTime: formatDateTime(r.send_time),
|
||||||
|
// 付款时间(并列展示,方便对账)
|
||||||
|
payTime: x.payAt,
|
||||||
|
// 审核时间 = **考核基准**:决定进 13:00 轮还是 18:00 轮,已等时长也从它算
|
||||||
|
auditTime: x.auditAt,
|
||||||
|
// 基准时间实际取值(审核缺失时回退创建/付款)+ 来源标记
|
||||||
|
benchmarkTime: x.benchmarkAt,
|
||||||
|
benchmarkSource: x.benchmarkSource,
|
||||||
|
state: x.state,
|
||||||
|
logisticStatus: String(r.logistic_status_name || ''),
|
||||||
|
// 运单号 / 快递公司:有值说明仓库**已打单**(但还没揽收/回传发货时间)。
|
||||||
|
// 业务 2026-10-20 报「聚水潭显示已发货、页面显示未发货」,靠这两列能一眼看出卡在哪一步。
|
||||||
|
logisticId: String(r.logistic_id || '').trim(),
|
||||||
|
logisticCompany: String(r.logistic_company_name || '').trim(),
|
||||||
|
centerKey: x.center.key,
|
||||||
|
centerName: x.center.name,
|
||||||
|
warehouseName: x.warehouseName,
|
||||||
|
// 已等时长(小时,保留 1 位)与分钟(排序都用分钟)
|
||||||
|
waitedHours: Math.round((x.waited / 60) * 10) / 10,
|
||||||
|
overdueMinutes: x.waited,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const heldOrders = heldDetail.map((x) => {
|
||||||
|
const r = x.row;
|
||||||
|
const from = x.benchmarkAt || '';
|
||||||
|
return {
|
||||||
|
oId: String(r.inner_order_id || ''),
|
||||||
|
soId: String(r.so_id || ''),
|
||||||
|
shopName: String(r.shop_name || ''),
|
||||||
|
createdAt: x.createdAt,
|
||||||
|
sendTime: formatDateTime(r.send_time),
|
||||||
|
payTime: x.payAt,
|
||||||
|
auditTime: x.auditAt,
|
||||||
|
benchmarkTime: x.benchmarkAt,
|
||||||
|
benchmarkSource: x.benchmarkSource,
|
||||||
|
state: x.state,
|
||||||
|
holdReason: x.holdReason,
|
||||||
|
logisticStatus: String(r.logistic_status_name || ''),
|
||||||
|
logisticId: String(r.logistic_id || '').trim(),
|
||||||
|
logisticCompany: String(r.logistic_company_name || '').trim(),
|
||||||
|
centerKey: x.center.key,
|
||||||
|
centerName: x.center.name,
|
||||||
|
warehouseName: x.warehouseName,
|
||||||
|
heldDays: from ? Math.floor(diffMinutes(from, round.deadline) / 1440) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const centers = centerList.map((c) => {
|
||||||
|
const b = acc.get(c.key) || { pendingCount: 0, oldestDays: 0, heldCount: 0 };
|
||||||
|
return {
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
placeholder: c.placeholder,
|
||||||
|
warehouseCount: c.warehouses.length,
|
||||||
|
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
|
||||||
|
overdueUnshipped: b.pendingCount,
|
||||||
|
oldestDays: b.oldestDays,
|
||||||
|
heldCount: b.heldCount,
|
||||||
|
// 该仓在本轮的窗口上界(可能是仓级例外,如山东两仓 15:00、河南洛宁 13:00)
|
||||||
|
windowEnd: registry.centerWindowEnd(c.key, round),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 本轮有「仓级例外窗口」的仓(给前端提示用;没有就是空数组)
|
||||||
|
const windowOverrides = centerList
|
||||||
|
.filter((c) => registry.centerWindowEndHour(c.key, round.key) !== null)
|
||||||
|
.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
hour: registry.centerWindowEndHour(c.key, round.key),
|
||||||
|
windowEnd: registry.centerWindowEnd(c.key, round),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = centers.reduce((a, c) => {
|
||||||
|
a.overdueUnshipped += c.overdueUnshipped;
|
||||||
|
if (c.overdueUnshipped > 0) a.overdueCenterCount += 1;
|
||||||
|
if (c.oldestDays > a.oldestDays) a.oldestDays = c.oldestDays;
|
||||||
|
a.heldCount += c.heldCount || 0;
|
||||||
|
return a;
|
||||||
|
}, emptySummary());
|
||||||
|
summary.voidCount = voidCount;
|
||||||
|
summary.carrierSkipped = carrierSkipped;
|
||||||
|
|
||||||
|
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
|
||||||
|
// 🔥 2026-10-20 业务报「已发货的单还显示未发货」时才发现这里曾写死 stale:false ——
|
||||||
|
// 状态表是**准实时镜像**,上游 ETL 一停(实测当天 15:48 之后 0 行刷新),
|
||||||
|
// 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。
|
||||||
|
// 必须把滞后暴露到页面上,否则业务只会以为判定写错。
|
||||||
|
const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null;
|
||||||
|
const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES;
|
||||||
|
// 🔥 更狠的一条:快照**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
|
||||||
|
// 实测 2026-10-20 18:54:快照停在 15:48,18:00 核验时早已发出的单全被判「未发货」,
|
||||||
|
// 业务连续报了 3 单都是这个原因。这种时候数字必然偏大,必须红字告警。
|
||||||
|
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
|
||||||
|
const freshness = {
|
||||||
|
stateMaxQuery: snapshotAt,
|
||||||
|
ageMinutes: snapshotAgeMin,
|
||||||
|
stale,
|
||||||
|
coversDeadline,
|
||||||
|
hint: [
|
||||||
|
stale
|
||||||
|
? `聚水潭订单状态镜像最新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。`
|
||||||
|
: '',
|
||||||
|
!coversDeadline
|
||||||
|
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
|
||||||
|
+ '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。'
|
||||||
|
: '',
|
||||||
|
].filter(Boolean).join(' '),
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
generatedAt: formatDateTime(now),
|
||||||
|
date: round.date,
|
||||||
|
round: {
|
||||||
|
key: round.key,
|
||||||
|
label: round.label,
|
||||||
|
verifyHour: round.verifyHour,
|
||||||
|
windowEnd: round.windowEnd,
|
||||||
|
},
|
||||||
|
notStarted: false,
|
||||||
|
isPreviousDay: false,
|
||||||
|
rounds,
|
||||||
|
deadline: round.deadline,
|
||||||
|
// boundary='audit':轮次归属、已等时长都按**首次审核时间**算
|
||||||
|
window: {
|
||||||
|
start: round.windowStart,
|
||||||
|
end: round.windowEnd,
|
||||||
|
includeHistory: true,
|
||||||
|
boundary: 'audit',
|
||||||
|
measuredBy: 'audit',
|
||||||
|
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
|
||||||
|
overrides: windowOverrides,
|
||||||
|
},
|
||||||
|
summary,
|
||||||
|
centers,
|
||||||
|
orders,
|
||||||
|
// 不考核的快递(前端文案直接用,避免两边各写一份)
|
||||||
|
excludedCarriers: EXCLUDED_CARRIERS,
|
||||||
|
// 不计考核清单(待审核 / 异常)—— 与 orders 互斥
|
||||||
|
heldOrders,
|
||||||
|
truncated: pendingRows.length > orders.length,
|
||||||
|
detailLimit: limit,
|
||||||
|
detailDegraded: false,
|
||||||
|
freshness,
|
||||||
|
cached: false,
|
||||||
|
cacheAgeSec: 0,
|
||||||
|
note: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
resultCache = { key: cacheKey, at: Date.now(), data: payload };
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getShipTimeliness,
|
||||||
|
clearShipTimelinessCache,
|
||||||
|
DEFAULT_DETAIL_LIMIT,
|
||||||
|
MAX_DETAIL_LIMIT,
|
||||||
|
};
|
||||||
@ -525,6 +525,74 @@ test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ⑦-e 成品库存多仓合并(2026-10-17):
|
||||||
|
// 实物搬到「简尚-横林二仓品牌仓」(16132681) 时,归属仓 (15870495) 为 0,
|
||||||
|
// 只查归属仓会把有货的成品算成 0(本次 5 个误报缺货 + 2 个误报预警)。
|
||||||
|
test('replenish index: sums stock across brand warehouse whitelist (⑦-e)', async () => {
|
||||||
|
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||||
|
const svc = require('../src/services/mkhBomService');
|
||||||
|
const originalQuery = cloudDatabase.query;
|
||||||
|
const originalGetPool = cloudDatabase.getPool;
|
||||||
|
const captured = [];
|
||||||
|
|
||||||
|
cloudDatabase.query = async (sql) => {
|
||||||
|
captured.push(sql);
|
||||||
|
if (sql.includes('jst_packInventoryInfo')) {
|
||||||
|
// 箱表:同样的「老仓 0 / 新仓 10」格局
|
||||||
|
return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }];
|
||||||
|
}
|
||||||
|
if (sql.includes('jst_inventoryInfo')) {
|
||||||
|
// 关键:SUM 之后返回的是**多仓合计**。旧口径只查单仓时这里拿不到这笔货。
|
||||||
|
return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }];
|
||||||
|
}
|
||||||
|
if (sql.includes('pm_stockMonitor')) {
|
||||||
|
return [{
|
||||||
|
skuId: 'MKHDNZ0409260600788',
|
||||||
|
fifteenAvgSale: 1,
|
||||||
|
monitorStock: 0,
|
||||||
|
safetyDays: 3,
|
||||||
|
suggestStock: 0,
|
||||||
|
monitorDate: new Date('2026-10-17T00:00:00Z'),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) return [];
|
||||||
|
if (sql.includes('jst_qmcust_orderlist')) return [];
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
cloudDatabase.getPool = async () => ({
|
||||||
|
request: () => ({ input() {}, query: async () => ({ recordset: [] }) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const index = await svc._private.loadReplenishIndex();
|
||||||
|
const row = index.bySku.get('MKHDNZ0409260600788');
|
||||||
|
assert.equal(row.stock, 10, '多仓求和后必须看到新仓的 10 件,不能是 0');
|
||||||
|
|
||||||
|
// SQL 层必须真的按白名单多仓取数,而不是退回单仓
|
||||||
|
const invSql = captured.find((s) => s.includes('jst_inventoryInfo'));
|
||||||
|
assert.ok(invSql.includes('IN (15870495,16132681)'),
|
||||||
|
`实时库存必须按仓白名单取数,实际: ${invSql}`);
|
||||||
|
assert.ok(invSql.includes('GROUP BY') && invSql.includes('SUM('),
|
||||||
|
'实时库存必须多仓 SUM + GROUP BY(旧版是 ORDER BY updateTime DESC 取首条)');
|
||||||
|
const packSql = captured.find((s) => s.includes('jst_packInventoryInfo'));
|
||||||
|
assert.ok(packSql.includes('IN (15870495,16132681)'),
|
||||||
|
`箱表必须按同一份仓白名单取数,实际: ${packSql}`);
|
||||||
|
} finally {
|
||||||
|
cloudDatabase.query = originalQuery;
|
||||||
|
cloudDatabase.getPool = originalGetPool;
|
||||||
|
svc.clearMkhBomCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 白名单常量本身要被导出(新增同类仓时改这一处即可)
|
||||||
|
test('replenish index: brand warehouse whitelist contains main + Henglin No.2 brand warehouse', () => {
|
||||||
|
const svc = require('../src/services/mkhBomService');
|
||||||
|
const ids = svc._private.BRAND_WMS_CO_IDS;
|
||||||
|
assert.ok(Array.isArray(ids), 'BRAND_WMS_CO_IDS 必须是数组');
|
||||||
|
assert.ok(ids.includes(15870495), '必须含崔桥工厂品牌仓(归属主仓)');
|
||||||
|
assert.ok(ids.includes(16132681), '必须含横林二仓品牌仓(2026-10-13 新增)');
|
||||||
|
});
|
||||||
|
|
||||||
test('order index: reads both channel tables, merges and dedupes by o_id', async () => {
|
test('order index: reads both channel tables, merges and dedupes by o_id', async () => {
|
||||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||||
const svc = require('../src/services/mkhBomService');
|
const svc = require('../src/services/mkhBomService');
|
||||||
|
|||||||
@ -135,6 +135,10 @@ test('fetchPickingBinSkuRows 只统计真实拣选仓位,排除 Pack 箱记录
|
|||||||
assert.ok(!captured.sqlText.includes('b.binType IN'));
|
assert.ok(!captured.sqlText.includes('b.binType IN'));
|
||||||
assert.equal(captured.params.t0, undefined);
|
assert.equal(captured.params.t0, undefined);
|
||||||
assert.equal(captured.params.binTypes, undefined);
|
assert.equal(captured.params.binTypes, undefined);
|
||||||
|
// 多来源 union 后必须再按 仓库+商品 汇总,否则同一仓位行会被拆成多行
|
||||||
|
assert.ok(captured.sqlText.includes('shipAgg AS ('));
|
||||||
|
assert.ok(captured.sqlText.includes('LEFT JOIN shipAgg s'));
|
||||||
|
assert.ok(!captured.sqlText.includes('LEFT JOIN ship s'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('销量覆盖 4 个来源:线上普通行 + 组合装展开 + 全民客户普通行 + 全民客户组合装展开', async () => {
|
test('销量覆盖 4 个来源:线上普通行 + 组合装展开 + 全民客户普通行 + 全民客户组合装展开', async () => {
|
||||||
|
|||||||
527
backend/test/warehouseLogisticsException.test.js
Normal file
527
backend/test/warehouseLogisticsException.test.js
Normal file
@ -0,0 +1,527 @@
|
|||||||
|
/**
|
||||||
|
* 仓储中心物流转运异常 —— 口径回归测试(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) {
|
||||||
|
const calls = { times: 0, sql: [], params: [] };
|
||||||
|
const handler = async (sql, params) => {
|
||||||
|
calls.times += 1;
|
||||||
|
calls.sql.push(String(sql));
|
||||||
|
calls.params.push(params || {});
|
||||||
|
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) {
|
||||||
|
const realDb = require.cache[dbPath];
|
||||||
|
const calls = stubDb(stubRows);
|
||||||
|
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 opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||||
|
const first = await svc.getLogisticsExceptions(opts);
|
||||||
|
assert.equal(first.cached, false);
|
||||||
|
assert.equal(calls.times, 1);
|
||||||
|
|
||||||
|
const second = await svc.getLogisticsExceptions(opts);
|
||||||
|
assert.equal(second.cached, true);
|
||||||
|
assert.equal(calls.times, 1, '第二次应命中缓存');
|
||||||
|
|
||||||
|
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
|
||||||
|
assert.equal(third.cached, false);
|
||||||
|
assert.equal(calls.times, 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('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => {
|
||||||
|
await withStub(async (svc, calls) => {
|
||||||
|
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||||
|
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||||
|
assert.equal(calls.times, 1);
|
||||||
|
|
||||||
|
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
|
||||||
|
await svc.getLogisticsExceptionExportRows(opts);
|
||||||
|
assert.equal(calls.times, 2, '导出是首次查,应真查库');
|
||||||
|
|
||||||
|
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||||
|
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
|
||||||
|
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('未到 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
394
backend/test/warehouseLogisticsExceptionNotify.test.js
Normal file
394
backend/test/warehouseLogisticsExceptionNotify.test.js
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「仓储中心物流转运异常」每天推文本到钉钉群 —— 正文组装与通道回归测试
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 这里只测四件容易悄悄坏掉的事:
|
||||||
|
* ① 群消息正文的数字与明细(业务照着这条消息干活,数字错了没人能发现);
|
||||||
|
* ② 0 条 / 明细条数上限 / 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
|
||||||
|
* ③ webhook 地址与加签(sign 算错就是 310000,肉眼看不出来);
|
||||||
|
* ④ 钉钉报错必须原样透出 errcode/errmsg(业务排错全靠这个码)。
|
||||||
|
* 不测数据库与真实网络:settings / job 的 DB 分支在接口层验证。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const job = require('../src/jobs/warehouseLogisticsExceptionNotifyJob');
|
||||||
|
const sender = require('../src/services/dingtalkRobotWebhookSender');
|
||||||
|
const settings = require('../src/services/warehouseLogisticsExceptionNotifySettings');
|
||||||
|
|
||||||
|
function sampleData(overrides = {}) {
|
||||||
|
return {
|
||||||
|
batch: {
|
||||||
|
label: '10-22 09:00 核验 · 10-21 发货',
|
||||||
|
verifyDate: '2026-10-22',
|
||||||
|
shipDate: '2026-10-21',
|
||||||
|
deadline: '2026-10-22 09:00:00',
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
shippedOrders: 9886,
|
||||||
|
packageCount: 9886,
|
||||||
|
exceptionOrders: 143,
|
||||||
|
exceptionPackages: 144,
|
||||||
|
exceptionRate: 1.4,
|
||||||
|
centerCount: 9,
|
||||||
|
byType: { PICKED_NO_TRANSIT: 137, NOT_PICKED: 7 },
|
||||||
|
},
|
||||||
|
centers: [
|
||||||
|
{ key: 'sn', name: '睢宁仓', shippedOrders: 1200, exceptionOrders: 1, exceptionRate: 0.1 },
|
||||||
|
{ key: 'cq', name: '崔桥工厂仓', shippedOrders: 3000, exceptionOrders: 2, exceptionRate: 0.1 },
|
||||||
|
{ key: 'hl', name: '横林二仓', shippedOrders: 800, exceptionOrders: 0, exceptionRate: 0 },
|
||||||
|
],
|
||||||
|
types: [
|
||||||
|
{ key: 'PICKED_NO_TRANSIT', label: '已揽收未中转', desc: '揽收后没有下一步中转记录' },
|
||||||
|
{ key: 'NOT_PICKED', label: '未揽收', desc: '快递还没来揽' },
|
||||||
|
],
|
||||||
|
exclusions: {
|
||||||
|
carriers: ['唯品顺丰', '顺丰', '京喜京东'],
|
||||||
|
conditionalCarriers: [{ keyword: '睢宁中通', keepCenters: ['睢宁仓'] }],
|
||||||
|
orderStatusKeywords: ['取消', '作废'],
|
||||||
|
orderLabelKeywords: ['发货后取消'],
|
||||||
|
orderTypeLabels: ['刷单', '转空包'],
|
||||||
|
carrierPackages: 12,
|
||||||
|
voidedPackages: 3,
|
||||||
|
nonRealPackages: 5,
|
||||||
|
},
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
centerKey: 'cq',
|
||||||
|
warehouseName: '崔桥工厂仓',
|
||||||
|
oId: '9845001',
|
||||||
|
soId: '3318',
|
||||||
|
shopName: '京东自营',
|
||||||
|
carrier: '京东快递',
|
||||||
|
trackingNo: 'JD1',
|
||||||
|
logisticStatus: '已揽收',
|
||||||
|
exceptionLabel: '已揽收未中转',
|
||||||
|
exceptionDesc: '揽收后没有下一步中转记录',
|
||||||
|
sendTime: '2026-10-21 16:00:00',
|
||||||
|
lastLogisticTime: '2026-10-21 21:00:00',
|
||||||
|
pendingHours: 17,
|
||||||
|
lastLogisticInfo: '已揽收',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
centerKey: 'sn',
|
||||||
|
warehouseName: '睢宁仓',
|
||||||
|
oId: '9844248',
|
||||||
|
soId: '3316',
|
||||||
|
shopName: '天猫旗舰店',
|
||||||
|
carrier: '睢宁德邦',
|
||||||
|
trackingNo: '',
|
||||||
|
logisticStatus: '',
|
||||||
|
exceptionLabel: '未揽收',
|
||||||
|
exceptionDesc: '快递还没来揽',
|
||||||
|
sendTime: '2026-10-21 14:22:10',
|
||||||
|
lastLogisticTime: '',
|
||||||
|
pendingHours: null,
|
||||||
|
lastLogisticInfo: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('群消息正文:抬头带「异常」关键词、数字与分类型、按仓只列有异常的仓', () => {
|
||||||
|
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30, pageUrl: 'https://ck.yydd.ai:8443/p' });
|
||||||
|
const text = built.text;
|
||||||
|
|
||||||
|
// 钉钉「自定义关键词」安全设置一般配「异常」,抬头必须含这两个字
|
||||||
|
assert.match(text, /【仓储物流转运异常】/);
|
||||||
|
assert.match(text, /10-22 09:00 核验 · 10-21 发货/);
|
||||||
|
assert.match(text, /已发货 9886 单 \/ 9886 个包裹,异常 144 个包裹(1\.4%)/);
|
||||||
|
// 分类型按数量降序,且必须是可读标签(不是 PICKED_NO_TRANSIT 这种 key)
|
||||||
|
assert.match(text, /类型:已揽收未中转 137 · 未揽收 7/);
|
||||||
|
// 剔除数量 = 12 + 3 + 5
|
||||||
|
assert.match(text, /已剔除 20 个包裹/);
|
||||||
|
// 按仓只列有异常的:睢宁 1、崔桥 1;横林二仓 0 条不能出现
|
||||||
|
assert.match(text, /按仓(异常包裹):/);
|
||||||
|
assert.match(text, /睢宁仓 1 个(发货 1200 单)/);
|
||||||
|
assert.match(text, /崔桥工厂仓 1 个(发货 3000 单)/);
|
||||||
|
assert.ok(!text.includes('横林二仓'), '没有异常的仓不该占行');
|
||||||
|
// 页码链接带核验日
|
||||||
|
assert.match(text, /查看全部 \/ 导出 Excel:https:\/\/ck\.yydd\.ai:8443\/p\?date=2026-10-22/);
|
||||||
|
|
||||||
|
assert.equal(built.rowCount, 144);
|
||||||
|
assert.equal(built.listedCount, 2);
|
||||||
|
assert.equal(built.truncated, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('明细行:仓|内部单号|快递|异常类型|发货时间|待处理,字段里换行要压平', () => {
|
||||||
|
const line = job.buildDetailLine({
|
||||||
|
warehouseName: '崔桥工厂仓',
|
||||||
|
oId: '9845001',
|
||||||
|
carrier: '京东快递',
|
||||||
|
exceptionLabel: '已揽收未中转',
|
||||||
|
sendTime: '2026-10-21 16:00:00',
|
||||||
|
pendingHours: 17.25,
|
||||||
|
lastLogisticInfo: '第一行\n第二行',
|
||||||
|
}, 3);
|
||||||
|
|
||||||
|
assert.equal(line, '3. 崔桥工厂仓|9845001|京东快递|已揽收未中转|10-21 16:00|17.3h');
|
||||||
|
|
||||||
|
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
|
||||||
|
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
|
||||||
|
// 空值兜底:不能让消息里出现「undefined|null」
|
||||||
|
assert.equal(
|
||||||
|
job.buildDetailLine({}, 1),
|
||||||
|
'1. 未知仓|无单号|未知快递|异常|—|—'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
|
||||||
|
const data = sampleData({
|
||||||
|
rows: [],
|
||||||
|
summary: {
|
||||||
|
...sampleData().summary,
|
||||||
|
exceptionOrders: 0,
|
||||||
|
exceptionPackages: 0,
|
||||||
|
exceptionRate: 0,
|
||||||
|
byType: {},
|
||||||
|
centerCount: 0,
|
||||||
|
},
|
||||||
|
exclusions: {},
|
||||||
|
});
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||||
|
assert.match(built.text, /本批无转运异常,全部正常/);
|
||||||
|
assert.match(built.text, /异常 0 个包裹(/);
|
||||||
|
assert.ok(!built.text.includes('明细前'), '0 条时不该出现明细标题');
|
||||||
|
assert.equal(built.rowCount, 0);
|
||||||
|
assert.equal(built.listedCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('明细条数受配置控制:0 = 只发汇总,超量只列前 N 条并提示剩余', () => {
|
||||||
|
const zero = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
|
||||||
|
assert.ok(!/明细前 \d+ 条/.test(zero.text), 'maxDetailRows=0 时不要明细块');
|
||||||
|
assert.match(zero.text, /明细共 144 条,消息里不放,请点下面链接查看/);
|
||||||
|
|
||||||
|
const one = job.buildNotifyText(sampleData(), { maxDetailRows: 1 });
|
||||||
|
assert.equal(one.listedCount, 1);
|
||||||
|
assert.ok(one.text.includes('1. 崔桥工厂仓'), '只列第一条');
|
||||||
|
assert.ok(!one.text.includes('2. 睢宁仓'), '第二条应被截掉');
|
||||||
|
assert.match(one.text, /其余 143 条见页面/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('明细太多时按字节精简:不超 20000 字节上限,且不切断半个汉字', () => {
|
||||||
|
// 造 60 条很长的明细(仓名 144 字 ≈ 432 字节/行),必然超过 text 上限
|
||||||
|
const longName = '崔桥工厂半成品包装项目仓'.repeat(12);
|
||||||
|
const rows = Array.from({ length: 60 }, (_item, index) => ({
|
||||||
|
centerKey: 'cq',
|
||||||
|
warehouseName: longName,
|
||||||
|
oId: `9845${String(index).padStart(3, '0')}`,
|
||||||
|
carrier: '中通快递',
|
||||||
|
exceptionLabel: '已揽收未中转',
|
||||||
|
sendTime: '2026-10-21 16:00:00',
|
||||||
|
pendingHours: 17,
|
||||||
|
}));
|
||||||
|
const data = sampleData({
|
||||||
|
rows,
|
||||||
|
summary: { ...sampleData().summary, exceptionPackages: rows.length, exceptionOrders: rows.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
const built = job.buildNotifyText(data, { maxDetailRows: 50 });
|
||||||
|
assert.ok(built.bytes <= sender.MAX_CONTENT_BYTES, `实得 ${built.bytes} 字节,超了上限`);
|
||||||
|
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, '应留出安全余量');
|
||||||
|
assert.equal(built.truncated, true, '应标记为已精简');
|
||||||
|
assert.ok(built.listedCount < 50, '应自动少列几条');
|
||||||
|
assert.ok(!built.text.includes('\uFFFD'), '不能出现被截断产生的乱码');
|
||||||
|
// 削掉明细后仍保留抬头和链接
|
||||||
|
assert.match(built.text, /【仓储物流转运异常】/);
|
||||||
|
assert.match(built.text, /查看全部/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('按字节截断工具:中文按 3 字节算,不切半个字', () => {
|
||||||
|
assert.deepEqual(sender.truncateBytes('abc', 10), { text: 'abc', truncated: false });
|
||||||
|
// 「中」= 3 字节:10 字节最多放 3 个字(9 字节)
|
||||||
|
const cut = sender.truncateBytes('中文字符', 10);
|
||||||
|
assert.equal(cut.text, '中文字');
|
||||||
|
assert.equal(cut.truncated, true);
|
||||||
|
assert.ok(Buffer.byteLength(cut.text, 'utf8') <= 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('时区:容器是 UTC,推送时刻仍按北京时间判(09:10 不能变成 01:10)', () => {
|
||||||
|
// 2026-10-22T01:10Z = 北京时间 09:10
|
||||||
|
assert.equal(job.chinaHourMinute(new Date('2026-10-22T01:10:00Z')), '09:10');
|
||||||
|
assert.equal(job.chinaDateKey(new Date('2026-10-22T01:10:00Z')), '2026-10-22');
|
||||||
|
// 北京时间 00:30(UTC 前一天 16:30)→ 日期必须是新的一天
|
||||||
|
assert.equal(job.chinaDateKey(new Date('2026-10-21T16:30:00Z')), '2026-10-22');
|
||||||
|
assert.equal(job.chinaHourMinute(new Date('2026-10-21T16:30:00Z')), '00:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('推送时间 / 明细条数:非法输入一律退回默认值,不能把定时任务配挂', () => {
|
||||||
|
assert.equal(settings.normalizeSendTime('08:05'), '08:05');
|
||||||
|
assert.equal(settings.normalizeSendTime('9:10'), '09:10');
|
||||||
|
assert.equal(settings.normalizeSendTime('25:00'), '09:10');
|
||||||
|
assert.equal(settings.normalizeSendTime(''), '09:10');
|
||||||
|
assert.equal(settings.normalizeSendTime(undefined), '09:10');
|
||||||
|
|
||||||
|
assert.equal(settings.normalizeMaxDetailRows(undefined), 30);
|
||||||
|
assert.equal(settings.normalizeMaxDetailRows(-5), 0);
|
||||||
|
assert.equal(settings.normalizeMaxDetailRows(999), 50);
|
||||||
|
assert.equal(settings.normalizeMaxDetailRows('12'), 12);
|
||||||
|
assert.equal(settings.normalizeMaxDetailRows('abc'), 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('配置校验:只要有 Webhook 就算配好;加签密钥只回掩码', () => {
|
||||||
|
assert.deepEqual(settings.missingFields({ webhookUrl: '' }), ['Webhook 地址']);
|
||||||
|
assert.deepEqual(settings.missingFields({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }), []);
|
||||||
|
assert.equal(settings.isWarehouseLogisticsExceptionNotifyReady({}), false);
|
||||||
|
assert.equal(
|
||||||
|
settings.isWarehouseLogisticsExceptionNotifyReady({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }),
|
||||||
|
true,
|
||||||
|
'安全设置用「关键词」时不需要加签密钥'
|
||||||
|
);
|
||||||
|
|
||||||
|
const admin = settings.toAdminConfig({
|
||||||
|
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t',
|
||||||
|
webhookSecret: 'SEC0123456789abcdef',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
assert.equal(admin.webhookSecretConfigured, true);
|
||||||
|
assert.ok(!JSON.stringify(admin).includes('SEC0123456789abcdef'), '明文加签密钥不能出现在返回体里');
|
||||||
|
assert.match(admin.webhookSecretPreview, /\*+/);
|
||||||
|
// Webhook 要完整回显:业务需要核对 access_token
|
||||||
|
assert.match(admin.webhookUrl, /access_token=t/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Webhook 地址校验:必须是 robot/send 且带 access_token', () => {
|
||||||
|
const ok = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
|
||||||
|
assert.equal(sender.normalizeWebhookUrl(ok), ok);
|
||||||
|
|
||||||
|
assert.throws(() => sender.normalizeWebhookUrl(''), /请填写/);
|
||||||
|
assert.throws(() => sender.normalizeWebhookUrl('oapi.dingtalk.com/robot/send'), /不是合法 URL/);
|
||||||
|
// 常见的复制错误:贴成了企业内部应用的地址 / 少复制了路径
|
||||||
|
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/robot/send'), /缺少 access_token/);
|
||||||
|
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/topapi/robot/send?access_token=a'), /不像群机器人/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('加签:sign = urlEncode(base64(HmacSHA256(secret, `${ts}\\n${secret}`)))', () => {
|
||||||
|
const webhook = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
|
||||||
|
const secret = 'SECtestsecret';
|
||||||
|
const ts = 1700000000000;
|
||||||
|
|
||||||
|
const signed = new URL(sender.buildSignedUrl(webhook, secret, ts));
|
||||||
|
assert.equal(signed.searchParams.get('timestamp'), String(ts));
|
||||||
|
assert.equal(signed.searchParams.get('access_token'), 'abc123', '原参数要保留');
|
||||||
|
|
||||||
|
// 手工算一遍(顺序写错就是 310000,这条断言就是防这个)
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac('sha256', secret)
|
||||||
|
.update(`${ts}\n${secret}`, 'utf8')
|
||||||
|
.digest('base64');
|
||||||
|
// searchParams 会自动 decode,所以这里拿到的是原始 base64(含 + / =)
|
||||||
|
assert.equal(signed.searchParams.get('sign'), expected);
|
||||||
|
|
||||||
|
// 没配密钥(安全设置用关键词/IP):只追加 timestamp,不带 sign
|
||||||
|
const plain = new URL(sender.buildSignedUrl(webhook, '', ts));
|
||||||
|
assert.equal(plain.searchParams.get('sign'), null);
|
||||||
|
assert.equal(plain.searchParams.get('timestamp'), String(ts));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 钉钉通道:stub fetch 验请求形状与错误透传 ────────────────────────────────
|
||||||
|
|
||||||
|
function stubFetch(handler) {
|
||||||
|
const calls = [];
|
||||||
|
const original = global.fetch;
|
||||||
|
global.fetch = async (url, options = {}) => {
|
||||||
|
calls.push({ url: String(url), options });
|
||||||
|
const body = handler.body === undefined ? {} : handler.body;
|
||||||
|
return new Response(JSON.stringify(body), { status: handler.status || 200 });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
restore: () => { global.fetch = original; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('发文本:msgtype=text,加签参数拼在 URL 上,@所有人开关透传', async () => {
|
||||||
|
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
|
||||||
|
try {
|
||||||
|
const result = await sender.sendTextToWebhook({
|
||||||
|
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=abc123',
|
||||||
|
secret: 'SECtestsecret',
|
||||||
|
content: '【仓储物流转运异常】测试',
|
||||||
|
atAll: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.delivered, true);
|
||||||
|
assert.equal(result.errmsg, 'ok');
|
||||||
|
|
||||||
|
const call = stub.calls[0];
|
||||||
|
assert.equal(call.options.method, 'POST');
|
||||||
|
assert.match(call.options.headers['Content-Type'], /application\/json/);
|
||||||
|
// 加签后 URL 必须带 timestamp + sign
|
||||||
|
const url = new URL(call.url);
|
||||||
|
assert.ok(url.searchParams.get('timestamp'), '缺少 timestamp');
|
||||||
|
assert.ok(url.searchParams.get('sign'), '缺少 sign');
|
||||||
|
|
||||||
|
const body = JSON.parse(call.options.body);
|
||||||
|
assert.equal(body.msgtype, 'text');
|
||||||
|
assert.equal(body.text.content, '【仓储物流转运异常】测试');
|
||||||
|
assert.equal(body.at.isAtAll, true);
|
||||||
|
assert.deepEqual(body.at.atMobiles, []);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('发文本:空内容不发请求(避免钉钉 400 把错误码埋掉)', async () => {
|
||||||
|
let touched = 0;
|
||||||
|
const original = global.fetch;
|
||||||
|
global.fetch = async () => { touched += 1; throw new Error('不应该发请求'); };
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => sender.sendTextToWebhook({
|
||||||
|
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||||||
|
content: ' ',
|
||||||
|
}),
|
||||||
|
/内容为空/
|
||||||
|
);
|
||||||
|
assert.equal(touched, 0);
|
||||||
|
} finally {
|
||||||
|
global.fetch = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('发文本:钉钉 errcode 原样透出(310000 关键词/加签不匹配是最常见的坑)', async () => {
|
||||||
|
const stub = stubFetch({ body: { errcode: 310000, errmsg: 'sign not match' } });
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => sender.sendTextToWebhook({
|
||||||
|
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||||||
|
secret: 'SECwrong',
|
||||||
|
content: '【仓储物流转运异常】测试',
|
||||||
|
}),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.code, 310000);
|
||||||
|
assert.match(error.message, /310000/);
|
||||||
|
assert.match(error.message, /sign not match/);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('发文本:超长正文自动截断后仍会发出去(不是直接抛错)', async () => {
|
||||||
|
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
|
||||||
|
try {
|
||||||
|
const huge = '异常'.repeat(12000); // 24000 字 ≈ 72000 字节,远超上限
|
||||||
|
const result = await sender.sendTextToWebhook({
|
||||||
|
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||||||
|
content: huge,
|
||||||
|
});
|
||||||
|
assert.equal(result.truncated, true);
|
||||||
|
assert.ok(result.bytes <= sender.MAX_CONTENT_BYTES);
|
||||||
|
|
||||||
|
const body = JSON.parse(stub.calls[0].options.body);
|
||||||
|
assert.ok(Buffer.byteLength(body.text.content, 'utf8') <= sender.MAX_CONTENT_BYTES);
|
||||||
|
} finally {
|
||||||
|
stub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
667
backend/test/warehouseShipTimeliness.test.js
Normal file
667
backend/test/warehouseShipTimeliness.test.js
Normal file
@ -0,0 +1,667 @@
|
|||||||
|
/**
|
||||||
|
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版)
|
||||||
|
*
|
||||||
|
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义:
|
||||||
|
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走;
|
||||||
|
* ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、
|
||||||
|
* 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段;
|
||||||
|
* ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)——
|
||||||
|
* 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错;
|
||||||
|
* ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**:
|
||||||
|
* 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。
|
||||||
|
* ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做,
|
||||||
|
* 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废;
|
||||||
|
* ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。
|
||||||
|
* 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回;
|
||||||
|
* ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃;
|
||||||
|
* **作废单(已取消 / 合并 / 拆单父单)不考核**;
|
||||||
|
* ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。
|
||||||
|
*
|
||||||
|
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
|
||||||
|
*/
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const registry = require('../src/services/warehouseShipTimelinessRegistry');
|
||||||
|
|
||||||
|
// ── ① 主仓归属:最长匹配 ────────────────────────────────────────────────
|
||||||
|
test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => {
|
||||||
|
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCenter 能捞到各种命名的子仓', () => {
|
||||||
|
const cases = [
|
||||||
|
['简尚-运营C横林二仓专用仓', 'HL2'],
|
||||||
|
['简尚-横林二仓京东仓', 'HL2'],
|
||||||
|
['京仓-横林二仓京东自营仓', 'HL2'],
|
||||||
|
['简尚-崔桥工厂仓唯品仓', 'CQ'],
|
||||||
|
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
|
||||||
|
['简尚-新苏北临时仓', 'XSB'],
|
||||||
|
['简尚-分销部睢宁专用仓', 'SN'],
|
||||||
|
['京仓-山东青岗集京东仓储', 'SD_QGJ'],
|
||||||
|
['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'],
|
||||||
|
['简尚-河南洛宁仓', 'HN_LN'],
|
||||||
|
];
|
||||||
|
for (const [name, expected] of cases) {
|
||||||
|
assert.equal(registry.resolveCenter(name)?.key, expected, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('「京仓-横林二仓京东云仓」业务指定排除,不计入横林二仓', () => {
|
||||||
|
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null);
|
||||||
|
// ⚠️ 不能连带排掉名字相近的仓 —— 关键词必须写完整片段
|
||||||
|
assert.equal(registry.resolveCenter('简尚-横林二仓京东仓')?.key, 'HL2');
|
||||||
|
assert.equal(registry.resolveCenter('京仓-横林二仓京东自营仓')?.key, 'HL2');
|
||||||
|
assert.equal(registry.resolveCenter('京仓-崔桥工厂仓京东云仓')?.key, 'CQ');
|
||||||
|
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
|
||||||
|
const shouldBeNull = [
|
||||||
|
'工厂-山东菏泽森美代发仓',
|
||||||
|
'工厂-山东恒思代发仓',
|
||||||
|
'工厂-河南千优代发仓',
|
||||||
|
'工厂-河北霸州派雅世佳代发仓',
|
||||||
|
'简尚-苏州福利品仓',
|
||||||
|
];
|
||||||
|
for (const name of shouldBeNull) {
|
||||||
|
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => {
|
||||||
|
const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP');
|
||||||
|
assert.ok(c, 'CQ_WIP 必须存在');
|
||||||
|
assert.equal(c.placeholder, undefined, '已接入真实仓,不能再是占位仓');
|
||||||
|
assert.equal(c.name, '崔桥工厂半成品包装项目仓');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓')?.key, 'CQ_WIP');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓')?.key, 'CQ');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓')?.key, 'CQ_BRAND');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
|
||||||
|
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
|
||||||
|
|
||||||
|
test('13:00 前不做核验,返回 null(不回填昨日数据)', () => {
|
||||||
|
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
|
||||||
|
assert.equal(registry.resolveActiveRound(at(12, 59)), null);
|
||||||
|
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('13:00~17:59 用 13:00 轮,窗口是「截止今日 07:00 前创建(含历史)」', () => {
|
||||||
|
for (const [h, m] of [[13, 0], [15, 30], [17, 59]]) {
|
||||||
|
const r = registry.resolveActiveRound(at(h, m));
|
||||||
|
assert.ok(r, `${h}:${m} 应已进入首轮核验`);
|
||||||
|
assert.equal(r.key, 'NOON');
|
||||||
|
// 核验时刻 = 13:00(判定「发了没发 / 超时多久」用它)
|
||||||
|
assert.equal(r.deadline, '2026-10-20 13:00:00');
|
||||||
|
assert.equal(r.verifyHour, 13);
|
||||||
|
// 订单窗口上界 = 07:00(只核验该时刻前创建的单)
|
||||||
|
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
|
||||||
|
assert.notEqual(r.deadline, r.windowEnd, '核验时刻与窗口上界必须是两个值');
|
||||||
|
assert.equal(r.includeHistory, true);
|
||||||
|
assert.notEqual(r.windowStart, '2026-10-20 00:00:00', '下界不能卡在当天 00:00');
|
||||||
|
assert.equal(r.windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => {
|
||||||
|
const r = registry.resolveActiveRound(at(18, 0));
|
||||||
|
assert.equal(r.key, 'AFTERNOON');
|
||||||
|
assert.equal(r.deadline, '2026-10-20 18:00:00');
|
||||||
|
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
|
||||||
|
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于「等 18:00 轮」)', () => {
|
||||||
|
const rounds = registry.describeRounds(at(16, 5));
|
||||||
|
assert.deepEqual(
|
||||||
|
rounds.map((r) => [r.key, r.deadline.slice(11, 16), r.windowEnd.slice(11, 16)]),
|
||||||
|
[['NOON', '13:00', '07:00'], ['AFTERNOON', '18:00', '16:00']]
|
||||||
|
);
|
||||||
|
assert.equal(rounds[0].active, true);
|
||||||
|
assert.equal(rounds[1].active, false, '16:05 还没到 18:00 轮');
|
||||||
|
assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('审核晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => {
|
||||||
|
const [noon, afternoon] = registry.describeRounds(at(16, 5));
|
||||||
|
assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00');
|
||||||
|
assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00',
|
||||||
|
'18:00 是当天最后一轮 → 次日 13:00');
|
||||||
|
// 跨月边界也要对
|
||||||
|
assert.equal(
|
||||||
|
registry.nextRoundDeadline({ key: 'AFTERNOON', date: '2026-10-31' }),
|
||||||
|
'2026-11-01 13:00:00'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ③ 常量口径 ──────────────────────────────────────────────────────────
|
||||||
|
test('数据源必须是聚水潭实时状态表,不是订单表', () => {
|
||||||
|
assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => {
|
||||||
|
assert.equal(registry.SHIPPING_STATUS, '发货中');
|
||||||
|
assert.equal(registry.UNAUDITED_STATUS, '已支付待审核');
|
||||||
|
assert.equal(registry.ABNORMAL_STATUS, '异常');
|
||||||
|
// 页面/路由仍叫「24 小时发货时效」,但判定是 13:00/18:00 到点即超时
|
||||||
|
assert.equal(registry.SHIP_TIMELINESS_HOURS, 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('作废单(已取消)明确不考核', () => {
|
||||||
|
assert.ok(
|
||||||
|
registry.IGNORED_STATES.includes('已取消'),
|
||||||
|
'「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍'
|
||||||
|
);
|
||||||
|
assert.ok(registry.IGNORED_STATES.includes('已发货'));
|
||||||
|
assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建时间要从订单表补,且只补窄列', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
[...registry.CREATED_SOURCE_TABLES],
|
||||||
|
['jst_Orders', 'jst_qmcust_orderlist'],
|
||||||
|
'两张订单表都要查(店铺互斥,交集为 0)'
|
||||||
|
);
|
||||||
|
assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '必须分批,不能一次 IN 上万个');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||||||
|
function stubDb(dbPath, scenario = 'default') {
|
||||||
|
const wmsId = 15038426;
|
||||||
|
|
||||||
|
// 创建时间(展示)+ 作废事实来源:o_id → 订单表行
|
||||||
|
// 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间
|
||||||
|
const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o });
|
||||||
|
|
||||||
|
const defaultFacts = {
|
||||||
|
1: f('jst_Orders', { created: '2026-09-25 00:50:00' }),
|
||||||
|
2: f('jst_Orders', { created: '2026-10-20 16:20:00' }),
|
||||||
|
6: f('jst_Orders', { created: '2026-10-20 17:00:00' }),
|
||||||
|
7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }),
|
||||||
|
9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }),
|
||||||
|
// 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例
|
||||||
|
// 5127276315127080023(内部单号 9486414)就是这样被误判成超时的
|
||||||
|
10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }),
|
||||||
|
// 11:状态表 send_time 空,订单表 send_date 有值(已发)
|
||||||
|
11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }),
|
||||||
|
// 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货)
|
||||||
|
// —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」
|
||||||
|
37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }),
|
||||||
|
// 12:拆单父单,status 不是 Split 但 is_split=1
|
||||||
|
12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }),
|
||||||
|
13: f('jst_Orders', { created: '2026-10-20 15:00:00' }),
|
||||||
|
// 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮)
|
||||||
|
31: f('jst_Orders', { created: '2026-10-20 15:30:00' }),
|
||||||
|
32: f('jst_Orders', { created: '2026-10-20 14:30:00' }),
|
||||||
|
33: f('jst_Orders', { created: '2026-10-20 13:30:00' }),
|
||||||
|
34: f('jst_Orders', { created: '2026-10-20 12:30:00' }),
|
||||||
|
// 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476)
|
||||||
|
// 只能进 18:00 轮,不能进 13:00 轮
|
||||||
|
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
||||||
|
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
|
||||||
|
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
|
||||||
|
const noonFacts = {
|
||||||
|
21: f('jst_Orders', { created: '2026-10-20 06:45:00' }),
|
||||||
|
22: f('jst_Orders', { created: '2026-10-20 05:55:00' }),
|
||||||
|
23: f('jst_Orders', { created: '2026-10-19 19:55:00' }),
|
||||||
|
24: f('jst_Orders', { created: '2026-10-20 07:30:00' }),
|
||||||
|
// 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算
|
||||||
|
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
||||||
|
// 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算
|
||||||
|
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const factsMap = scenario === 'noon' ? noonFacts : defaultFacts;
|
||||||
|
|
||||||
|
const mk = (o) => ({
|
||||||
|
shop_name: '测试店',
|
||||||
|
logistic_status_name: '',
|
||||||
|
query_time: '2026-10-20 11:18:27',
|
||||||
|
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
|
||||||
|
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultRows = [
|
||||||
|
// 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时
|
||||||
|
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 01:00:00' }),
|
||||||
|
// 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间)
|
||||||
|
mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 16:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 16:30:00' }),
|
||||||
|
// 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时
|
||||||
|
mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-26 00:10:00',
|
||||||
|
last_oa_confirm_time: '2026-09-26 00:15:00' }),
|
||||||
|
// 4:已支付待审核(还没审单、没有审核时间)→ 不计考核
|
||||||
|
mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00',
|
||||||
|
last_oa_confirm_time: null }),
|
||||||
|
// 5:异常 → 不计考核
|
||||||
|
mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-23 01:05:00' }),
|
||||||
|
// 6:审核 17:05 晚于窗口上界 → 本轮不该核验
|
||||||
|
mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 16:55:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 17:05:00' }),
|
||||||
|
// 7:被业务排除的京东云仓 → 不归属任何主仓
|
||||||
|
mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-25 00:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 00:10:00' }),
|
||||||
|
// 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重
|
||||||
|
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 01:00:00' }),
|
||||||
|
// 9:作废单(已取消)—— 业务定性不考核
|
||||||
|
mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '已取消', pay_time: '2026-09-21 08:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-21 08:10:00' }),
|
||||||
|
// 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除
|
||||||
|
mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-21 09:59:00',
|
||||||
|
last_oa_confirm_time: '2026-09-21 10:05:00' }),
|
||||||
|
// 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除
|
||||||
|
mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-21 10:59:00',
|
||||||
|
last_oa_confirm_time: '2026-09-21 11:05:00' }),
|
||||||
|
// 12:拆单父单(is_split=1)—— 自己不发货,必须剔除
|
||||||
|
mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-21 11:59:00',
|
||||||
|
last_oa_confirm_time: '2026-09-21 12:05:00' }),
|
||||||
|
// 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉,
|
||||||
|
// 本版必须算超时(业务口径:到点未发就算超时)
|
||||||
|
mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 14:59:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 15:30:00' }),
|
||||||
|
// ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ──
|
||||||
|
// 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验
|
||||||
|
mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 15:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 15:35:00' }),
|
||||||
|
// 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时
|
||||||
|
mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 14:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 14:35:00' }),
|
||||||
|
// 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验
|
||||||
|
mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 13:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:35:00' }),
|
||||||
|
// 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时
|
||||||
|
mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 12:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 12:35:00' }),
|
||||||
|
// 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。
|
||||||
|
// 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分
|
||||||
|
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
|
||||||
|
first_oa_confirm_time: '2026-10-20 13:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:10:00' }),
|
||||||
|
// 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10)
|
||||||
|
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
|
||||||
|
first_oa_confirm_time: '2026-10-19 10:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:30:00' }),
|
||||||
|
// 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。
|
||||||
|
// 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped
|
||||||
|
mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-25 01:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }),
|
||||||
|
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
|
||||||
|
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
|
||||||
|
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
|
||||||
|
last_oa_confirm_time: '2026-09-22 09:05:00' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
|
||||||
|
const noonRows = [
|
||||||
|
// 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分
|
||||||
|
mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 06:40:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 07:20:00' }),
|
||||||
|
// 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛)
|
||||||
|
mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 05:50:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 06:40:00' }),
|
||||||
|
// 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到
|
||||||
|
mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-19 19:50:00',
|
||||||
|
last_oa_confirm_time: '2026-10-19 20:10:00' }),
|
||||||
|
// 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间)
|
||||||
|
mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 07:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 07:40:00' }),
|
||||||
|
// 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。
|
||||||
|
// 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分
|
||||||
|
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
|
||||||
|
first_oa_confirm_time: '2026-10-19 10:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:30:00' }),
|
||||||
|
// 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算,
|
||||||
|
// 不能显示 0 分(真实案例:创建 10-18 的单今天才审)
|
||||||
|
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
|
||||||
|
first_oa_confirm_time: '2026-10-20 13:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:10:00' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = scenario === 'noon' ? noonRows : defaultRows;
|
||||||
|
|
||||||
|
const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] };
|
||||||
|
|
||||||
|
const handler = async (sql, params) => {
|
||||||
|
const text = String(sql);
|
||||||
|
if (/jst_warehouseInfo/u.test(text)) {
|
||||||
|
return [
|
||||||
|
{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId },
|
||||||
|
{ Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 },
|
||||||
|
{ Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 },
|
||||||
|
{ Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表
|
||||||
|
if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) {
|
||||||
|
calls.backfill += 1;
|
||||||
|
calls.backfillSql.push(text);
|
||||||
|
const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders';
|
||||||
|
const m = text.match(/IN \(([0-9,\s]+)\)/u);
|
||||||
|
const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : [];
|
||||||
|
return ids
|
||||||
|
.filter((id) => factsMap[id] && factsMap[id].table === table)
|
||||||
|
.map((id) => {
|
||||||
|
const x = factsMap[id];
|
||||||
|
return {
|
||||||
|
o_id: id,
|
||||||
|
created: x.created,
|
||||||
|
status: x.status,
|
||||||
|
send_date: x.send_date,
|
||||||
|
is_merge: x.is_merge,
|
||||||
|
is_split: x.is_split,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
calls.state += 1;
|
||||||
|
calls.sql.push(text);
|
||||||
|
calls.params.push(params || {});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
require.cache[dbPath] = {
|
||||||
|
id: dbPath,
|
||||||
|
filename: dbPath,
|
||||||
|
loaded: true,
|
||||||
|
exports: { query: handler, querySlow: handler },
|
||||||
|
};
|
||||||
|
return { calls, wmsId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStubbedService(fn, scenario = 'default') {
|
||||||
|
return async () => {
|
||||||
|
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||||
|
const realDb = require.cache[dbPath];
|
||||||
|
const ctx = stubDb(dbPath, scenario);
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||||
|
try {
|
||||||
|
const svc = require('../src/services/warehouseShipTimelinessService');
|
||||||
|
await fn(svc, ctx);
|
||||||
|
} finally {
|
||||||
|
if (realDb) require.cache[dbPath] = realDb;
|
||||||
|
else delete require.cache[dbPath];
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => {
|
||||||
|
// 18:05 → 18:00 轮:窗口上界 16:00(按审核时间),核验时刻 18:00
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
|
||||||
|
const c = data.centers.find((x) => x.key === 'CQ');
|
||||||
|
assert.ok(c, '应能归到崔桥工厂仓');
|
||||||
|
|
||||||
|
// 超时 = 1(09-25 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时)
|
||||||
|
// + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核)
|
||||||
|
// = 6 单
|
||||||
|
// 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发
|
||||||
|
// 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的
|
||||||
|
assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时');
|
||||||
|
assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天');
|
||||||
|
assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单');
|
||||||
|
|
||||||
|
assert.equal(data.summary.overdueUnshipped, 7);
|
||||||
|
assert.equal(data.summary.overdueCenterCount, 3);
|
||||||
|
assert.equal(data.summary.oldestDays, 25);
|
||||||
|
assert.equal(data.summary.heldCount, 2);
|
||||||
|
|
||||||
|
// 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示
|
||||||
|
assert.equal(data.orders.length, 7);
|
||||||
|
assert.equal(String(data.orders[0].oId), '1');
|
||||||
|
assert.equal(data.orders[0].auditTime, '2026-09-25 01:00:00', '基准是审核时间');
|
||||||
|
assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00', '创建时间只作展示');
|
||||||
|
assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00');
|
||||||
|
assert.equal(data.orders[0].benchmarkSource, 'last_audit');
|
||||||
|
// 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟
|
||||||
|
assert.equal(data.orders[0].overdueMinutes, 37020);
|
||||||
|
|
||||||
|
// 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算
|
||||||
|
const fresh = data.orders.find((o) => String(o.oId) === '13');
|
||||||
|
assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)');
|
||||||
|
assert.equal(fresh.overdueMinutes, 150);
|
||||||
|
assert.equal(fresh.waitedHours, 2.5);
|
||||||
|
|
||||||
|
// 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290
|
||||||
|
const lateAudit = data.orders.find((o) => String(o.oId) === '26');
|
||||||
|
assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)');
|
||||||
|
assert.equal(lateAudit.overdueMinutes, 290);
|
||||||
|
|
||||||
|
// 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10
|
||||||
|
const reAudited = data.orders.find((o) => String(o.oId) === '25');
|
||||||
|
assert.ok(reAudited, '重审单也要算');
|
||||||
|
assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核');
|
||||||
|
assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30');
|
||||||
|
|
||||||
|
// 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间
|
||||||
|
const third = data.orders.find((o) => String(o.oId) === '3');
|
||||||
|
assert.ok(third, '补不到创建时间的单也要算超时');
|
||||||
|
assert.equal(third.createdAt, '', '确实没补到');
|
||||||
|
assert.equal(third.benchmarkSource, 'last_audit');
|
||||||
|
assert.equal(third.payTime, '2026-09-26 00:10:00');
|
||||||
|
|
||||||
|
// 创建时间晚于窗口上界 → 本轮不核验(2 号:创建 16:20、6 号:创建 17:00)
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验');
|
||||||
|
|
||||||
|
// ── 仓级例外窗口(业务 2026-10-20:山东两仓 15:00、河南洛宁 13:00)──
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '31'),
|
||||||
|
'山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界,18:00 轮不核验');
|
||||||
|
assert.ok(data.orders.some((o) => String(o.oId) === '32'),
|
||||||
|
'山东品牌仓 14:30 创建 → 早于该仓 15:00 上界,要核验');
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '33'),
|
||||||
|
'河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界,18:00 轮不核验');
|
||||||
|
assert.ok(data.orders.some((o) => String(o.oId) === '34'),
|
||||||
|
'河南洛宁仓 12:30 创建 → 早于该仓 13:00 上界,要核验');
|
||||||
|
assert.equal(data.window.overrides.length, 3, '山东两仓 + 河南洛宁 三处例外');
|
||||||
|
assert.deepEqual(
|
||||||
|
data.window.overrides.map((o) => [o.key, o.hour]).sort(),
|
||||||
|
[['HN_LN', 13], ['SD_BRAND', 15], ['SD_QGJ', 15]]
|
||||||
|
);
|
||||||
|
// 例外只作用在 18:00 轮;13:00 轮没有例外(见下一个用例)
|
||||||
|
assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u);
|
||||||
|
// 被排除的仓不能算
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
|
||||||
|
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
|
||||||
|
for (const id of ['9', '10', '11', '12']) {
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
|
||||||
|
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
|
||||||
|
}
|
||||||
|
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
|
||||||
|
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
|
||||||
|
|
||||||
|
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
|
||||||
|
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
|
||||||
|
assert.ok(
|
||||||
|
!data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)),
|
||||||
|
'不考核快递的单不能出现在超时明细里'
|
||||||
|
);
|
||||||
|
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']);
|
||||||
|
|
||||||
|
// 不计考核清单
|
||||||
|
assert.equal(data.heldOrders.length, 2);
|
||||||
|
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
|
||||||
|
assert.deepEqual(reasons, ['待审核', '订单异常'].sort());
|
||||||
|
|
||||||
|
// 新鲜度直接取自主查询的 query_time,不再额外扫表
|
||||||
|
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27');
|
||||||
|
// 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新,
|
||||||
|
// 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。
|
||||||
|
assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟');
|
||||||
|
assert.equal(
|
||||||
|
data.freshness.stale,
|
||||||
|
data.freshness.ageMinutes > 90,
|
||||||
|
'滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)'
|
||||||
|
);
|
||||||
|
if (data.freshness.stale) {
|
||||||
|
assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示');
|
||||||
|
}
|
||||||
|
// 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」
|
||||||
|
assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false');
|
||||||
|
assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚');
|
||||||
|
|
||||||
|
// ── 性能护栏 ────────────────────────────────────────────────────────
|
||||||
|
assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)');
|
||||||
|
const sql = calls.sql[0];
|
||||||
|
assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表');
|
||||||
|
assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判');
|
||||||
|
// 本版基准是审核时间 → 两列审核时间都必须在投影里
|
||||||
|
assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列');
|
||||||
|
assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底');
|
||||||
|
// 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消)
|
||||||
|
assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数');
|
||||||
|
assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断');
|
||||||
|
assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用');
|
||||||
|
// 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s
|
||||||
|
assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列');
|
||||||
|
assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列');
|
||||||
|
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
|
||||||
|
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
|
||||||
|
|
||||||
|
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
|
||||||
|
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
|
||||||
|
for (const bs of calls.backfillSql) {
|
||||||
|
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
|
||||||
|
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
|
||||||
|
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
|
||||||
|
'多取一列就会把 0.2s 拖到 38s');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ 窗口参数走 params,不在 SQL 里手写
|
||||||
|
assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
|
||||||
|
assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除');
|
||||||
|
assert.equal(calls.params[0].shipStatus, '发货中');
|
||||||
|
|
||||||
|
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
|
||||||
|
assert.equal(data.round.key, 'AFTERNOON');
|
||||||
|
assert.match(String(data.round.windowEnd), /16:00:00$/u);
|
||||||
|
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端');
|
||||||
|
assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算');
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('13:00 轮:只核验「今天 00:00~07:00 审核」的单 + 历史未发', withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
|
||||||
|
assert.equal(data.round.key, 'NOON');
|
||||||
|
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
|
||||||
|
|
||||||
|
// 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验)
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '21'),
|
||||||
|
'今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现');
|
||||||
|
// 24 号:今天 07:40 审核 → 同理不核验
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '24'),
|
||||||
|
'今天 07:40 审核 → 不进 13:00 轮');
|
||||||
|
// 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '26'),
|
||||||
|
'下午才审核的老单不能进 13:00 轮');
|
||||||
|
|
||||||
|
// 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛)
|
||||||
|
const b = data.orders.find((o) => String(o.oId) === '22');
|
||||||
|
assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时');
|
||||||
|
assert.equal(b.benchmarkSource, 'last_audit');
|
||||||
|
assert.equal(b.overdueMinutes, 380);
|
||||||
|
assert.equal(b.waitedHours, 6.3);
|
||||||
|
// 23 号:昨天审核、至今未发 → 历史未发池也要看到
|
||||||
|
const d = data.orders.find((o) => String(o.oId) === '23');
|
||||||
|
assert.ok(d, '含历史未发单(历史不受 00:00 起限制)');
|
||||||
|
assert.equal(d.overdueMinutes, 1010);
|
||||||
|
|
||||||
|
// 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '25'),
|
||||||
|
'重审到 13:30 的单归 18:00 轮,不进 13:00 轮');
|
||||||
|
|
||||||
|
// 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单
|
||||||
|
assert.equal(data.summary.overdueUnshipped, 2);
|
||||||
|
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判');
|
||||||
|
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
|
||||||
|
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
|
||||||
|
}, 'noon'));
|
||||||
|
|
||||||
|
test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbedService(async (svc, { calls }) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 });
|
||||||
|
assert.equal(data.notStarted, true);
|
||||||
|
assert.equal(data.round, null);
|
||||||
|
assert.equal(data.orders.length, 0);
|
||||||
|
assert.equal(data.summary.overdueUnshipped, 0);
|
||||||
|
// 11 个主仓仍然列出来(结构不能塌),但数字全为 0
|
||||||
|
assert.equal(data.centers.length, registry.CENTERS.length);
|
||||||
|
assert.ok(data.centers.every((x) => x.overdueUnshipped === 0));
|
||||||
|
assert.equal(calls.state, 0, '未到核验时间不应查库');
|
||||||
|
assert.equal(calls.backfill, 0, '未到核验时间也不该回订单表');
|
||||||
|
assert.match(data.note, /13:00/u);
|
||||||
|
assert.equal(data.nextRoundAt, '2026-10-20 13:00:00');
|
||||||
|
assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00');
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── ⑧ 时区:生产容器是 UTC,库里时间是北京时间 ──────────────────────────
|
||||||
|
// 2026-10-21 实测:容器 /etc/timezone=Etc/UTC,mssql useUTC:false 把 GETDATE()
|
||||||
|
// 构造成「墙钟当 UTC」的 Date → 比真实 now 快 8 小时。北京时间 00:00~08:00
|
||||||
|
// 服务端取到的是「昨天」,13:00 轮因此把整个历史未发池算进来(3895 单 vs 本地 6 单)。
|
||||||
|
test('beijingNow:任何机器时区下读出来的本地时间都等于北京时间', () => {
|
||||||
|
// 真实时刻 2026-10-21T00:04:00Z = 北京时间 2026-10-21 08:04
|
||||||
|
const base = new Date('2026-10-21T00:04:00Z');
|
||||||
|
const bj = registry.beijingNow(base);
|
||||||
|
const stamp = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
+ `-${String(d.getDate()).padStart(2, '0')} `
|
||||||
|
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
assert.equal(stamp(bj), '2026-10-21 08:04', '平移后本地 getter 必须读出北京时间');
|
||||||
|
|
||||||
|
// 跨日场景:真实时刻 2026-10-20T23:48:00Z = 北京时间 2026-10-21 07:48(UTC 上还是「昨天」)
|
||||||
|
const midnight = registry.beijingNow(new Date('2026-10-20T23:48:00Z'));
|
||||||
|
assert.equal(stamp(midnight).slice(0, 10), '2026-10-21',
|
||||||
|
'北京时间 00:00~08:00 不能取成前一天');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器时区', () => {
|
||||||
|
// 北京时间 2026-10-21 07:48(UTC 上是 10-20 23:48)→ 还没到 13:00,应当 notStarted
|
||||||
|
const round = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-20T23:48:00Z')));
|
||||||
|
assert.equal(round, null, '北京时间 07:48 未到 13:00 首轮核验');
|
||||||
|
// 北京时间 2026-10-21 13:30 → 13:00 轮生效,窗口上界 07:00、核验 13:00
|
||||||
|
const noon = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-21T05:30:00Z')));
|
||||||
|
assert.equal(noon.key, 'NOON');
|
||||||
|
assert.equal(noon.date, '2026-10-21');
|
||||||
|
assert.equal(noon.deadline, '2026-10-21 13:00:00');
|
||||||
|
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
|
||||||
|
});
|
||||||
@ -7,8 +7,9 @@
|
|||||||
"test": "node --test test/*.test.mjs",
|
"test": "node --test test/*.test.mjs",
|
||||||
"prebuild": "node ./scripts/write-build-version.mjs",
|
"prebuild": "node ./scripts/write-build-version.mjs",
|
||||||
"dev": "node ./scripts/write-build-version.mjs && vite --configLoader native",
|
"dev": "node ./scripts/write-build-version.mjs && vite --configLoader native",
|
||||||
"build": "vite build && node ./scripts/verify-planning-entry.mjs",
|
"build": "node ./scripts/verify-router-lazy.mjs && vite build && node ./scripts/verify-planning-entry.mjs",
|
||||||
"check": "npm run lint && npm test && npm run build",
|
"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",
|
"verify:public-build": "node ./scripts/verify-public-build.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
70
frontend/scripts/verify-router-lazy.mjs
Normal file
70
frontend/scripts/verify-router-lazy.mjs
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
40
frontend/src/api/binReplenish.js
Normal file
40
frontend/src/api/binReplenish.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
// 缺货补货监控(拣货位缺货 → 从存货位补货)
|
||||||
|
// params.warehouse: changzhou / wh7 / suining / henglin2 / subei / henglin3(缺省 = 常州主仓)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getPickBinMeta = (warehouse) => request.get(
|
||||||
|
'/api/bin-replenish/pick-bins',
|
||||||
|
{ params: warehouse ? { warehouse } : {} }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const importPickBins = (file, warehouse) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (warehouse) formData.append('warehouse', warehouse);
|
||||||
|
return request.post('/api/bin-replenish/pick-bins', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: LONG_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearPickBins = (warehouse) => request.delete(
|
||||||
|
'/api/bin-replenish/pick-bins',
|
||||||
|
{ params: warehouse ? { warehouse } : {} }
|
||||||
|
);
|
||||||
59
frontend/src/api/warehouseLogisticsException.js
Normal file
59
frontend/src/api/warehouseLogisticsException.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
23
frontend/src/api/warehouseShipTimeliness.js
Normal file
23
frontend/src/api/warehouseShipTimeliness.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
// 发货时效要扫两张订单表 + 出库表(rpt 下全是视图),冷启动实测 40~50 秒,
|
||||||
|
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
||||||
|
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||||
|
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
||||||
|
// params.limit 明细条数上限
|
||||||
|
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
||||||
|
'/api/warehouse-ship-timeliness/list',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
timeout: TIMELINESS_TIMEOUT_MS,
|
||||||
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 仅取今日两轮核验的描述(不打订单表,秒回),用于顶部提示
|
||||||
|
export const getWarehouseShipTimelinessRounds = () => request.get(
|
||||||
|
'/api/warehouse-ship-timeliness/rounds',
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
@ -29,6 +29,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
|
{ 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,
|
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
|
||||||
children: [
|
children: [
|
||||||
@ -36,6 +37,8 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
|||||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||||
|
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
|
||||||
{
|
{
|
||||||
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const SIDEBAR_PAGES = [
|
|||||||
{ pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
{ 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_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: '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) => {
|
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
SunOutlined,
|
SunOutlined,
|
||||||
|
WarningOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Dropdown, Switch } from 'antd';
|
import { Dropdown, Switch } from 'antd';
|
||||||
import { verifyToken } from '../api/auth';
|
import { verifyToken } from '../api/auth';
|
||||||
@ -70,6 +71,7 @@ const SIDEBAR_ICON_MAP = {
|
|||||||
shopping: ShoppingOutlined,
|
shopping: ShoppingOutlined,
|
||||||
table: TableOutlined,
|
table: TableOutlined,
|
||||||
team: TeamOutlined,
|
team: TeamOutlined,
|
||||||
|
warning: WarningOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIDEBAR_ROUTE_MAP = {
|
const SIDEBAR_ROUTE_MAP = {
|
||||||
@ -119,9 +121,24 @@ const SIDEBAR_ROUTE_MAP = {
|
|||||||
activePath: '/planning/operation/mkh-bom-data',
|
activePath: '/planning/operation/mkh-bom-data',
|
||||||
matchMode: 'prefix',
|
matchMode: 'prefix',
|
||||||
},
|
},
|
||||||
|
'warehouse-ship-timeliness': {
|
||||||
|
path: '/planning/operation/warehouse-ship-timeliness',
|
||||||
|
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' },
|
'daily-inventory': { path: '/planning/operation/daily-inventory' },
|
||||||
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
|
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
|
||||||
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-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' },
|
settings: { path: '/settings' },
|
||||||
'online-desktop-users': { path: '/settings/online-desktop-users' },
|
'online-desktop-users': { path: '/settings/online-desktop-users' },
|
||||||
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
|
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
|
||||||
|
|||||||
517
frontend/src/pages/BinReplenishMonitor/index.jsx
Normal file
517
frontend/src/pages/BinReplenishMonitor/index.jsx
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Empty, Input, InputNumber, Popconfirm, Segmented, Select, Spin, Table, Tag, Tooltip, Upload,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
DeleteOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
clearPickBins, exportBinReplenish, getBinReplenishList, importPickBins,
|
||||||
|
} 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 = {
|
||||||
|
pickout: { color: 'red', label: '拣货位缺货' },
|
||||||
|
empty: { color: 'default', label: '无货可补' },
|
||||||
|
low: { color: 'orange', label: '拣货位偏低' },
|
||||||
|
nopick: { color: 'blue', label: '未设拣货位' },
|
||||||
|
ok: { color: 'green', label: '正常' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ label: '待补货', value: 'replenish' },
|
||||||
|
{ label: '无货可补', value: 'empty' },
|
||||||
|
{ label: '未设拣货位', value: 'nopick' },
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TARGET_DAYS = 7;
|
||||||
|
|
||||||
|
const renderBinTags = (bins = [], emptyText = '未维护', limit = 3) => {
|
||||||
|
if (!bins.length) return <span className="brp-muted">{emptyText}</span>;
|
||||||
|
const visible = bins.slice(0, limit);
|
||||||
|
const rest = bins.length - visible.length;
|
||||||
|
return (
|
||||||
|
<span className="brp-bins">
|
||||||
|
{visible.map((item) => (
|
||||||
|
<Tag key={`${item.bin}-${item.qty}`} className={`brp-bin${item.qty > 0 ? '' : ' is-zero'}`}>
|
||||||
|
{item.bin}
|
||||||
|
<b>{num(item.qty)}</b>
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{rest > 0 ? (
|
||||||
|
<Tooltip title={bins.slice(limit).map((item) => `${item.bin}(${num(item.qty)})`).join('、')}>
|
||||||
|
<Tag className="brp-bin brp-bin--more">+{rest}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BinReplenishMonitor = () => {
|
||||||
|
const [warehouse, setWarehouse] = useState('cuiqiao');
|
||||||
|
const [status, setStatus] = useState('replenish');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [targetDays, setTargetDays] = useState(DEFAULT_TARGET_DAYS);
|
||||||
|
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: [], pickBinMeta: null, updatedAt: '', cacheHit: false,
|
||||||
|
});
|
||||||
|
const [importing, setImporting] = useState(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,
|
||||||
|
targetDays,
|
||||||
|
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 || [],
|
||||||
|
pickBinMeta: payload.pickBinMeta || null,
|
||||||
|
updatedAt: payload.updatedAt || '',
|
||||||
|
cacheHit: Boolean(payload.cacheHit),
|
||||||
|
});
|
||||||
|
if (isRefresh) message.success('已重新回源取数');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message || '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [warehouse, status, keyword, targetDays, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [warehouse, status, page, pageSize, targetDays]);
|
||||||
|
|
||||||
|
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 pickBinMeta = data.pickBinMeta || {};
|
||||||
|
const usingFallback = pickBinMeta.source !== 'import';
|
||||||
|
|
||||||
|
const metrics = useMemo(() => ([
|
||||||
|
{
|
||||||
|
key: 'pickout',
|
||||||
|
tone: 'red',
|
||||||
|
label: '拣货位缺货',
|
||||||
|
value: summary.pickout || 0,
|
||||||
|
unit: '个 SKU',
|
||||||
|
foot: `可立即从存货位补 ${num(summary.suggestQtyTotal)} 件`,
|
||||||
|
tip: '拣货位库存为 0、存货位还有货:需要把存货位的库存补到拣货位',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'empty',
|
||||||
|
tone: 'grey',
|
||||||
|
label: '无货可补',
|
||||||
|
value: summary.empty || 0,
|
||||||
|
unit: '个 SKU',
|
||||||
|
foot: '拣货位与存货位都为 0,需采购',
|
||||||
|
tip: '拣货位库存为 0,且仓库里已经没有该商品的存货,补货要走走采购',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'low',
|
||||||
|
tone: 'orange',
|
||||||
|
label: '拣货位偏低',
|
||||||
|
value: summary.low || 0,
|
||||||
|
unit: '个 SKU',
|
||||||
|
foot: `低于 ${targetDays} 天销量目标`,
|
||||||
|
tip: `拣货位还有货,但低于目标库存(= 近15天日销 × ${targetDays},最少 ${10} 件)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qty',
|
||||||
|
tone: 'blue',
|
||||||
|
label: '建议补货合计',
|
||||||
|
value: summary.suggestQtyTotal || 0,
|
||||||
|
unit: '件',
|
||||||
|
foot: `覆盖 ${num(summary.withPickSlot)} 个有拣货位的 SKU`,
|
||||||
|
tip: '建议补货量 = min(存货位库存, 目标拣货位库存 − 当前拣货位库存)',
|
||||||
|
},
|
||||||
|
]), [summary, targetDays]);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const response = await exportBinReplenish({ warehouse, status, keyword, targetDays });
|
||||||
|
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控.xlsx`);
|
||||||
|
} catch (error_) {
|
||||||
|
const text = await readBlobErrorMessage(error_);
|
||||||
|
message.error(text || '导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (file) => {
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const response = await importPickBins(file, warehouse);
|
||||||
|
const result = response?.data?.data || {};
|
||||||
|
message.success(`已导入 ${result.importedRows || 0} 个拣货位,正在重新取数…`);
|
||||||
|
await load({ refresh: true, page: 1 });
|
||||||
|
setPage(1);
|
||||||
|
} catch (error_) {
|
||||||
|
message.error(error_?.message || '导入失败');
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
try {
|
||||||
|
await clearPickBins(warehouse);
|
||||||
|
message.success('已清除货位清单,回到「货位散件记录」回退口径');
|
||||||
|
setPage(1);
|
||||||
|
await load({ refresh: true, page: 1 });
|
||||||
|
} catch (error_) {
|
||||||
|
message.error(error_?.message || '清除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 112,
|
||||||
|
fixed: 'left',
|
||||||
|
render: (value) => {
|
||||||
|
const tag = STATUS_TAGS[value] || STATUS_TAGS.ok;
|
||||||
|
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: 'pickBins',
|
||||||
|
width: 300,
|
||||||
|
render: (bins, row) => (row.hasPickSlot ? renderBinTags(bins) : <span className="brp-muted">未维护拣货位</span>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '拣货位库存',
|
||||||
|
dataIndex: 'pickQty',
|
||||||
|
width: 108,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => (
|
||||||
|
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>{num(value)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '存货位库存',
|
||||||
|
dataIndex: 'storageQty',
|
||||||
|
width: 108,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => <span className="brp-qty">{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: 'targetQty',
|
||||||
|
width: 108,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => <span className="brp-qty brp-qty--muted">{num(value)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '存货位(前 5 个 / 数量)',
|
||||||
|
dataIndex: 'storageBins',
|
||||||
|
width: 300,
|
||||||
|
render: (bins) => renderBinTags(bins, '无存货', 3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '近15天日销',
|
||||||
|
dataIndex: 'avgSale',
|
||||||
|
width: 108,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => <span className="brp-qty brp-qty--muted">{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>把货补过去。
|
||||||
|
状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="brp-hero__meta">
|
||||||
|
<div>
|
||||||
|
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
拣货位口径:
|
||||||
|
<b className={usingFallback ? '' : 'brp-ok'}>
|
||||||
|
{usingFallback ? '默认拣选位(Bin 且非存货位)' : `导入清单 ${num(pickBinMeta.count)} 个`}
|
||||||
|
</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.nopick)}</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>
|
||||||
|
<Select
|
||||||
|
value={warehouse}
|
||||||
|
onChange={(value) => { setWarehouse(value); setPage(1); }}
|
||||||
|
style={{ width: 210 }}
|
||||||
|
options={(data.warehouses || []).map((item) => ({
|
||||||
|
value: item.key,
|
||||||
|
label: item.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</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((summary.pickout || 0) + (summary.low || 0))}`
|
||||||
|
: item.value === 'empty'
|
||||||
|
? `${item.label} ${num(summary.empty || 0)}`
|
||||||
|
: item.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="brp-toolbar__field">
|
||||||
|
<span className="brp-toolbar__label">目标天数(天)</span>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={90}
|
||||||
|
value={targetDays}
|
||||||
|
onChange={(value) => { setTargetDays(Number(value) || DEFAULT_TARGET_DAYS); setPage(1); }}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜商品编码 / 品名 / 供应商"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
/>
|
||||||
|
<span className="brp-toolbar__spacer" />
|
||||||
|
<Upload
|
||||||
|
accept=".xlsx,.xls,.csv,.txt"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleImport}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} loading={importing}>导入货位清单</Button>
|
||||||
|
</Upload>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={refreshing}
|
||||||
|
onClick={() => load({ refresh: true })}
|
||||||
|
>
|
||||||
|
刷新数据
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usingFallback ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
className="brp-alert"
|
||||||
|
message="拣货位口径:默认拣选位(pack_type = Bin 且非存货位),与「拣选库位动销分析」一致"
|
||||||
|
description="这是系统当前能确定的最准口径。如果业务另有一份拣货位清单,可以上传覆盖(只放货位号,如 XA01-01-11),上传后按清单判定,其余货位都算存货位。"
|
||||||
|
action={(
|
||||||
|
<Upload accept=".xlsx,.xls,.csv,.txt" showUploadList={false} beforeUpload={handleImport}>
|
||||||
|
<Button size="small" type="primary">上传清单</Button>
|
||||||
|
</Upload>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
className="brp-alert"
|
||||||
|
message={`拣货位口径:导入清单(${num(pickBinMeta.count)} 个货位)`}
|
||||||
|
description={`最近导入:${pickBinMeta.lastImport?.fileName || '—'} · ${pickBinMeta.lastImport?.importedRows || 0} 个货位 · ${pickBinMeta.lastImport?.importedAt ? new Date(pickBinMeta.lastImport.importedAt).toLocaleString('zh-CN') : '—'}。清单变更后重新上传即可覆盖。`}
|
||||||
|
action={(
|
||||||
|
<Popconfirm
|
||||||
|
title="清除后回到「货位散件记录」回退口径,确定吗?"
|
||||||
|
onConfirm={handleClear}
|
||||||
|
okText="清除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>清除清单</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
className="brp-alert"
|
||||||
|
message={error}
|
||||||
|
action={<Button size="small" onClick={() => load()}>重试</Button>}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Spin
|
||||||
|
spinning={loading}
|
||||||
|
tip="正在从仓位库存视图聚合,首次约 30~60 秒"
|
||||||
|
wrapperClassName="brp-table-spin"
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="skuId"
|
||||||
|
size="small"
|
||||||
|
className="brp-table"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.rows}
|
||||||
|
scroll={{ x: 1900 }}
|
||||||
|
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),按商品编码 + 货位汇总。
|
||||||
|
「拣货位库存」= 拣货位上的数量合计;「存货位库存」= 其余货位(含整箱、未分箱)的数量合计。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>拣货位</b>:副本没有货位类型字段,按导入的货位清单判定;未导入时回退为
|
||||||
|
pack_type = Bin 的记录(页面顶部会标出当前口径)。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>目标库存</b>:max(10, 近15天日销 × {targetDays});日销取 rpt.pm_stockMonitor 同仓最新快照的
|
||||||
|
fifteenAvgSale,取不到的按 10 件兜底。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>建议补货量</b>:min(存货位库存, 目标拣货位库存 − 当前拣货位库存),只在「拣货位缺货 / 偏低」时给数字;
|
||||||
|
「无货可补」表示拣货位和存货位都没货,要走走采购,不是仓库内部调拨。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>未设拣货位</b>:该 SKU 在拣货位清单里一个货位都没有(多为整箱直发、或还没维护拣货位)。
|
||||||
|
这类不算「拣货位缺货」,不进待补货清单,可在「未设拣货位」页签单独核对。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>时效</b>:仓位库存视图单次聚合约 35~45 秒,服务端缓存 10 分钟(启动时已预热);
|
||||||
|
点「刷新数据」会强制回源,需要等半分钟左右。
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BinReplenishMonitor;
|
||||||
303
frontend/src/pages/BinReplenishMonitor/style.css
Normal file
303
frontend/src/pages/BinReplenishMonitor/style.css
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
/* 缺货补货监控 —— 页头 / 指标卡 / 工具栏 / 明细表 */
|
||||||
|
|
||||||
|
.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--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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -953,6 +953,7 @@ const MkhBomData = () => {
|
|||||||
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
|
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<header className="mkh-header">
|
<header className="mkh-header">
|
||||||
<div className="mkh-header__heading">
|
<div className="mkh-header__heading">
|
||||||
<Title level={3}>MKH-BOM 资料</Title>
|
<Title level={3}>MKH-BOM 资料</Title>
|
||||||
|
|||||||
966
frontend/src/pages/WarehouseLogisticsException/index.jsx
Normal file
966
frontend/src/pages/WarehouseLogisticsException/index.jsx
Normal file
@ -0,0 +1,966 @@
|
|||||||
|
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>
|
||||||
|
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。
|
||||||
|
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
||||||
|
|| exclusions.nonRealOrders > 0) ? (
|
||||||
|
<>
|
||||||
|
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
||||||
|
+ (exclusions.nonRealOrders || 0)}</b> 单
|
||||||
|
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
||||||
|
刷单/信誉单 {exclusions.nonRealOrders || 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}
|
||||||
|
<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;
|
||||||
345
frontend/src/pages/WarehouseLogisticsException/style.css
Normal file
345
frontend/src/pages/WarehouseLogisticsException/style.css
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
546
frontend/src/pages/WarehouseShipTimeliness/index.jsx
Normal file
546
frontend/src/pages/WarehouseShipTimeliness/index.jsx
Normal file
@ -0,0 +1,546 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Empty, Modal, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { SycmPage } from '../../components/Common';
|
||||||
|
import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// 核验点只有 13:00 / 18:00 两个,10 分钟刷新一次足够。
|
||||||
|
// 接口要扫两张订单表 + 出库表(rpt 下全是视图),冷启动 40~60 秒,别跟着其它页面的轮询节奏跑。
|
||||||
|
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
const number = (value, digits = 0) => (
|
||||||
|
value === null || value === undefined || value === ''
|
||||||
|
? '0'
|
||||||
|
: Number(value).toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」。
|
||||||
|
// ⚠️ 口径放开后(含历史未发单)会出现跨天的超时,必须给「天」,否则显示成「8640 小时」。
|
||||||
|
const 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} 天`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「不计考核」清单的列(待审核 / 订单异常)。
|
||||||
|
* 业务反馈(2026-10-20):贴进来的一批单号在聚水潭查不到,实测真实状态是
|
||||||
|
* 「已取消(发货前售后)」或「异常」—— 这类单不该算仓库超时。
|
||||||
|
* 这里把它们单列出来,回答「为什么这些单没进超时列表」。
|
||||||
|
*/
|
||||||
|
const HELD_COLUMNS = [
|
||||||
|
{ title: '内部单号', dataIndex: 'oId', width: 100 },
|
||||||
|
{ title: '线上订单号', dataIndex: 'soId', width: 170, ellipsis: true },
|
||||||
|
{ title: '店铺', dataIndex: 'shopName', width: 170, ellipsis: true },
|
||||||
|
{ title: '发货仓', dataIndex: 'warehouseName', width: 160, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '挂起原因',
|
||||||
|
dataIndex: 'holdReason',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => (
|
||||||
|
<Tag bordered={false} color={v === '订单异常' ? 'red' : 'default'}>{v || '—'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '发货时间',
|
||||||
|
dataIndex: 'sendTime',
|
||||||
|
width: 178,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v, row) => {
|
||||||
|
if (v) return v;
|
||||||
|
const no = row?.logisticId;
|
||||||
|
if (no) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={`已打单:${row.logisticCompany || ''} ${no}(${row.logisticStatus || '未揽收'})`}>
|
||||||
|
<Tag bordered={false} color="blue">已打单 · {row.logisticStatus || '未揽收'}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Tag bordered={false} color="default">未发货</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '审核时间',
|
||||||
|
dataIndex: 'auditTime',
|
||||||
|
width: 152,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => (v || <span className="wst-detail__fallback">未审核</span>),
|
||||||
|
},
|
||||||
|
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||||
|
{ title: '已挂起', dataIndex: 'heldDays', width: 88, render: (v) => (v > 0 ? `${v} 天` : '—') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DETAIL_COLUMNS = [
|
||||||
|
// 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单)
|
||||||
|
{ title: '内部单号', dataIndex: 'oId', width: 110 },
|
||||||
|
// 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户)
|
||||||
|
{
|
||||||
|
title: '线上订单号',
|
||||||
|
dataIndex: 'soId',
|
||||||
|
width: 178,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => (v ? <span className="wst-detail__soid">{v}</span> : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '店铺',
|
||||||
|
dataIndex: 'shopName',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => (v || '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '发货仓',
|
||||||
|
dataIndex: 'warehouseName',
|
||||||
|
width: 170,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => (v || '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 发货时间 = 聚水潭回传的发货时间(业务 2026-10-20:明细里不要创建时间,要发货时间)
|
||||||
|
title: (
|
||||||
|
<Tooltip title="聚水潭回传的发货时间(send_time)。为空但有运单号的,说明仓库已打单、还没揽收/回传发货时间,会显示成「已打单」">
|
||||||
|
<span>发货时间</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'sendTime',
|
||||||
|
width: 178,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v, row) => {
|
||||||
|
if (v) return v;
|
||||||
|
const no = row?.logisticId;
|
||||||
|
if (no) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={`已打单:${row.logisticCompany || ''} ${no}(${row.logisticStatus || '未揽收'})。聚水潭还没回传发货时间,所以还没从超时列表里消失`}>
|
||||||
|
<Tag bordered={false} color="blue">已打单 · {row.logisticStatus || '未揽收'}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Tag bordered={false} color="default">未发货</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 审核时间 = **考核基准**,取聚水潭「审核时间」列(最后一次审核,重审会刷新)
|
||||||
|
title: (
|
||||||
|
<Tooltip title="= 聚水潭订单列表「审核时间」那一列(最后一次审核,重新审核会刷新)。今天 7:00 前审核的 13:00 核验;截止 16:00 前审核的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
|
||||||
|
<span>审核时间</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'auditTime',
|
||||||
|
width: 152,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v, row) => (v
|
||||||
|
? v
|
||||||
|
: (
|
||||||
|
<Tooltip title="没取到审核时间,已回退创建/付款时间参与分轮与计时">
|
||||||
|
<span className="wst-detail__fallback">{row?.benchmarkTime || '—'} *</span>
|
||||||
|
</Tooltip>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
|
||||||
|
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
// 发货状态:聚水潭实时状态(发货中 = 已审核未发货,即考核对象)
|
||||||
|
title: (
|
||||||
|
<Tooltip title="聚水潭订单实时状态。发货中 = 已审核、仓库还没发货">
|
||||||
|
<span>发货状态</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'state',
|
||||||
|
width: 104,
|
||||||
|
render: (v) => (
|
||||||
|
<Tag bordered={false} color={v === '发货中' ? 'orange' : 'default'}>{v || '—'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '物流状态',
|
||||||
|
dataIndex: 'logisticStatus',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<Tooltip title="从审核时间到本轮核验时刻(13:00 / 18:00)等了多久">
|
||||||
|
<span>已等时长</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'overdueMinutes',
|
||||||
|
width: 112,
|
||||||
|
render: (v) => <span className="wst-detail__overdue">{formatOverdue(v)}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心 24 小时发货时效(独立页面)
|
||||||
|
* ────────────────────────────────────────────────────────────────
|
||||||
|
* 业务口径(yanhui 2026-10-20):
|
||||||
|
* · 11 个主仓,子仓并入主仓一起算;
|
||||||
|
* · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验
|
||||||
|
* (含 7 点前未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
|
||||||
|
* 今天之前审核仍未发的(历史未发)两个轮次都算;
|
||||||
|
* **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点);
|
||||||
|
* 轮次归属和「已等时长」都按**审核时间**算(聚水潭「审核时间」列,最后一次审核);
|
||||||
|
* · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天;
|
||||||
|
* · 已出库 / 待出库以**聚水潭发货时间**为准(有 send_time = 已发);
|
||||||
|
* · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。
|
||||||
|
*
|
||||||
|
* 数据来自 /api/warehouse-ship-timeliness/list(service 层见
|
||||||
|
* backend/src/services/warehouseShipTimelinessService.js)。
|
||||||
|
*/
|
||||||
|
function WarehouseShipTimeliness() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
// 选中的主仓:为空时右侧展示全部仓的超时明细
|
||||||
|
const [activeCenter, setActiveCenter] = useState('');
|
||||||
|
// 「业务挂起(疑问单)」明细弹窗
|
||||||
|
const [heldOpen, setHeldOpen] = useState(false);
|
||||||
|
|
||||||
|
// force=true 时带 force=1 让后端绕过 5 分钟结果缓存重查库;
|
||||||
|
// 定时轮询走缓存(这张视图单次要几十秒,每轮都重查会把库压死)
|
||||||
|
const load = useCallback(async (force = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await getWarehouseShipTimeliness({ limit: 1000, force: force ? 1 : 0 });
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 orders = useMemo(() => (Array.isArray(data?.orders) ? data.orders : []), [data]);
|
||||||
|
// 业务挂起(疑问单)—— 与 orders 互斥,两者相加才是待出库池
|
||||||
|
const heldOrders = useMemo(
|
||||||
|
() => (Array.isArray(data?.heldOrders) ? data.heldOrders : []),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
const summary = data?.summary || {};
|
||||||
|
const round = data?.round || null;
|
||||||
|
const freshness = data?.freshness || null;
|
||||||
|
// 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字**
|
||||||
|
const notStarted = Boolean(data?.notStarted);
|
||||||
|
const hasRound = Boolean(round);
|
||||||
|
const cutoffText = String(round?.windowEnd || '').slice(11, 16);
|
||||||
|
const windowEndText = String(data?.window?.end || '').slice(11, 16);
|
||||||
|
// 仓级例外窗口:18:00 轮里 山东两仓 15:00、河南洛宁 13:00(业务 2026-10-20 指定)
|
||||||
|
const windowOverrides = Array.isArray(data?.window?.overrides) ? data.window.overrides : [];
|
||||||
|
const overrideText = windowOverrides
|
||||||
|
.map((o) => `${o.name} ${String(o.hour).padStart(2, '0')}:00`)
|
||||||
|
.join('、');
|
||||||
|
// 未到核验时间时的提示文案,全部由后端给,前端不写死时刻
|
||||||
|
const nextRoundText = String(data?.nextRoundAt || '').slice(11, 16) || '13:00';
|
||||||
|
const nextWindowEndText = String(data?.nextWindowEnd || '').slice(11, 16) || '07:00';
|
||||||
|
|
||||||
|
const activeCenterName = useMemo(() => {
|
||||||
|
if (!activeCenter) return '';
|
||||||
|
const hit = centers.find((c) => c.key === activeCenter);
|
||||||
|
return hit ? hit.name : activeCenter;
|
||||||
|
}, [activeCenter, centers]);
|
||||||
|
|
||||||
|
const detailOrders = useMemo(() => (
|
||||||
|
activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders
|
||||||
|
), [activeCenter, orders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SycmPage className="wst-page">
|
||||||
|
<header className="wst-header">
|
||||||
|
<Title level={3}>仓储中心 24 小时发货时效</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
按聚水潭订单实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" className="wst-note">
|
||||||
|
每天 <b>13:00</b> 核验「<b>今天 07:00 前审核</b>」、<b>18:00</b> 核验「<b>截止 16:00 前审核</b>」、
|
||||||
|
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前审核、至今未发的历史未发单</b>
|
||||||
|
(18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
|
||||||
|
13:00 之前不核验,显示「未到核验时间」。
|
||||||
|
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
|
||||||
|
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前审核</b>的单、
|
||||||
|
<b>河南洛宁仓</b> 只看 <b>13:00 前审核</b>的单(核验时刻仍是 18:00)。
|
||||||
|
<b>轮次与「已等时长」都按审核时间算</b>(= 聚水潭「审核时间」列,最后一次审核);
|
||||||
|
明细并列「审核时间 / 发货时间」,方便直接和聚水潭对账。
|
||||||
|
<b>已发 / 未发以聚水潭发货时间为准</b>:有发货时间就是已发,不再用订单表的发货字段反推
|
||||||
|
—— 该字段经常不回写,会把早就发货的单误判成超时。
|
||||||
|
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
||||||
|
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
||||||
|
<b>自提 / 中铁 / 壹米滴答 三个快递不考核</b>(客户自提或专线大件,仓库控制不了运输),
|
||||||
|
已按物流公司自动剔除。
|
||||||
|
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
||||||
|
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
|
||||||
|
</Text>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="wst-status" bordered={false}>
|
||||||
|
<div className="wst-status__row">
|
||||||
|
<div className="wst-status__round">
|
||||||
|
{hasRound ? (
|
||||||
|
<>
|
||||||
|
<span className="wst-status__badge">{round.label}</span>
|
||||||
|
<span className="wst-status__label">
|
||||||
|
核验今天 {cutoffText} 前审核的订单
|
||||||
|
</span>
|
||||||
|
<span className="wst-status__window">
|
||||||
|
含历史未发 · 到点未发即超时
|
||||||
|
{overrideText ? `(${overrideText} 为仓级例外)` : ''}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="wst-status__badge wst-status__badge--idle">未到核验时间</span>
|
||||||
|
<span className="wst-status__label">
|
||||||
|
{nextRoundText} 开始首轮核验
|
||||||
|
(核验今天 {nextWindowEndText} 前审核的订单)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Space size={8}>
|
||||||
|
{data?.generatedAt ? (
|
||||||
|
<Text type="secondary" className="wst-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>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
className="wst-alert"
|
||||||
|
message={error}
|
||||||
|
action={<Button size="small" onClick={load}>重试</Button>}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{freshness && (freshness.stale || freshness.coversDeadline === false) ? (
|
||||||
|
<Alert
|
||||||
|
// 快照没覆盖到核验时刻 → 数字必然偏大,用红色;只是滞后用黄色
|
||||||
|
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
className="wst-alert"
|
||||||
|
message={freshness.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后'}
|
||||||
|
description={freshness.hint}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="wst-stats">
|
||||||
|
<Card bordered={false} className="wst-stat">
|
||||||
|
<Statistic
|
||||||
|
title={(
|
||||||
|
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按审核时间分轮),不设满 24 小时门槛">
|
||||||
|
<span>超时未发货</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
value={notStarted ? 0 : (summary.overdueUnshipped ?? 0)}
|
||||||
|
suffix="单"
|
||||||
|
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||||
|
valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
className="wst-stat wst-stat--held"
|
||||||
|
onClick={() => setHeldOpen(true)}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={(
|
||||||
|
<Tooltip title="已支付待审核(还没审单,仓库没接到)和订单异常的单。这类单不进仓库考核,点开看明细。">
|
||||||
|
<span>不计考核 <span className="wst-stat__more">明细</span></span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
value={notStarted ? 0 : (summary.heldCount ?? 0)}
|
||||||
|
suffix="单"
|
||||||
|
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||||
|
valueStyle={{ color: (summary.heldCount || 0) > 0 ? '#8c8c8c' : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card bordered={false} className="wst-stat">
|
||||||
|
<Statistic
|
||||||
|
title="涉及仓库"
|
||||||
|
value={notStarted ? 0 : (summary.overdueCenterCount ?? 0)}
|
||||||
|
suffix="个"
|
||||||
|
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card bordered={false} className="wst-stat">
|
||||||
|
<Statistic
|
||||||
|
title="最久未发货"
|
||||||
|
value={notStarted ? 0 : (summary.oldestDays ?? 0)}
|
||||||
|
suffix="天"
|
||||||
|
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||||
|
valueStyle={{ color: (summary.oldestDays || 0) >= 7 ? '#d46b08' : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wst-body">
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
className="wst-centers"
|
||||||
|
title="仓储中心"
|
||||||
|
extra={<Text type="secondary" className="wst-centers__hint">点仓库看明细</Text>}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading && !data} size="small">
|
||||||
|
<ul className="wst-centers__list">
|
||||||
|
{centers.map((c) => {
|
||||||
|
const isActive = activeCenter === c.key;
|
||||||
|
const disabled = c.placeholder || notStarted;
|
||||||
|
return (
|
||||||
|
<li key={c.key}>
|
||||||
|
<Tooltip
|
||||||
|
placement="right"
|
||||||
|
title={c.placeholder
|
||||||
|
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
|
||||||
|
: notStarted
|
||||||
|
? `今日 ${nextRoundText} 首轮核验开始后才展示超时数据`
|
||||||
|
: `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前审核的单,`
|
||||||
|
+ `超时未发货 ${c.overdueUnshipped} 单`
|
||||||
|
+ `${c.oldestDays > 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}`
|
||||||
|
+ `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}`
|
||||||
|
+ (c.warehouseCount > 1 ? `\n含子仓 ${c.warehouseCount} 个` : '')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
'wst-center',
|
||||||
|
isActive ? 'is-active' : '',
|
||||||
|
!c.overdueUnshipped ? 'is-empty' : '',
|
||||||
|
disabled ? 'is-disabled' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => {
|
||||||
|
if (disabled) return;
|
||||||
|
setActiveCenter(isActive ? '' : c.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="wst-center__name">{c.name}</span>
|
||||||
|
{c.placeholder ? (
|
||||||
|
<span className="wst-center__badge wst-center__badge--none">未接入</span>
|
||||||
|
) : (
|
||||||
|
<span className={`wst-center__badge${c.overdueUnshipped > 0 ? ' is-warn' : ''}`}>
|
||||||
|
{notStarted ? '—' : (c.overdueUnshipped > 0 ? c.overdueUnshipped : '—')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
className="wst-detail"
|
||||||
|
title={(
|
||||||
|
<span>
|
||||||
|
超时未发货订单
|
||||||
|
<span className="wst-detail__scope">
|
||||||
|
{activeCenterName || '全部仓库'} · {detailOrders.length} 单
|
||||||
|
{data?.truncated && detailOrders.length >= (data?.detailLimit || 0)
|
||||||
|
? `(已截断,仅显示前 ${data.detailLimit} 条)`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
extra={activeCenter ? (
|
||||||
|
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>查看全部</button>
|
||||||
|
) : null}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading && !data}>
|
||||||
|
{notStarted ? (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={`未到核验时间,今日 ${nextRoundText} 开始首轮核验`}
|
||||||
|
/>
|
||||||
|
) : detailOrders.length ? (
|
||||||
|
<Table
|
||||||
|
rowKey={(r) => `${r.source}-${r.oId}`}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }}
|
||||||
|
dataSource={detailOrders}
|
||||||
|
columns={DETAIL_COLUMNS}
|
||||||
|
scroll={{ x: 1642 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本轮没有超时未发货的订单" />
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`不计考核(待审核 / 异常)· ${heldOrders.length} 单`}
|
||||||
|
open={heldOpen}
|
||||||
|
onCancel={() => setHeldOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={1080}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
className="wst-alert"
|
||||||
|
message="「已支付待审核」= 还没审单,仓库根本没接到;「订单异常」= 聚水潭标记异常(缺货/地址/风控等)。两类都不该算仓库超时,故单列于此。"
|
||||||
|
/>
|
||||||
|
{heldOrders.length ? (
|
||||||
|
<Table
|
||||||
|
rowKey={(r) => `${r.oId}`}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }}
|
||||||
|
dataSource={heldOrders}
|
||||||
|
columns={HELD_COLUMNS}
|
||||||
|
scroll={{ x: 1280 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本轮没有待审核 / 异常的单" />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Text type="secondary" className="wst-foot">
|
||||||
|
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
|
||||||
|
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
|
||||||
|
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
|
||||||
|
轮次与「已等时长」都按审核时间(聚水潭显示值)算。
|
||||||
|
发货 = 聚水潭回传的发货时间。
|
||||||
|
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
|
||||||
|
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
||||||
|
<b>自提 / 中铁 / 壹米滴答不考核</b>
|
||||||
|
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
||||||
|
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||||
|
</Text>
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WarehouseShipTimeliness;
|
||||||
255
frontend/src/pages/WarehouseShipTimeliness/style.css
Normal file
255
frontend/src/pages/WarehouseShipTimeliness/style.css
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
.wst-page {
|
||||||
|
padding: 16px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-header {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-header .ant-typography {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轮次状态条 ============ */
|
||||||
|
.wst-status {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-status__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-status__round {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-status__badge--idle {
|
||||||
|
background: rgba(136, 135, 128, 0.14);
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-status__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--sycm-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-status__window,
|
||||||
|
.wst-status__time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 统计卡 ============ */
|
||||||
|
/* 5 张卡(超时未出库 / 业务挂起 / 涉及仓库 / 最久未出库 / 合计金额)
|
||||||
|
用 grid 而不是 Row/Col:24 栅格除不尽 5,Row/Col 会把第 5 张挤到下一行。 */
|
||||||
|
.wst-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-stat {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「业务挂起」卡可点开明细 */
|
||||||
|
.wst-stat--held {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-stat--held:hover {
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-stat__more {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 主从两栏 ============ */
|
||||||
|
.wst-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-centers {
|
||||||
|
flex: none;
|
||||||
|
width: 264px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-centers__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-centers__list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-center:hover {
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-center.is-active {
|
||||||
|
border-color: rgba(22, 119, 255, 0.45);
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-center.is-empty .wst-center__name {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-center.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-center__name {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-center__badge.is-warn {
|
||||||
|
background: rgba(255, 77, 79, 0.16);
|
||||||
|
color: #cf1322;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-center__badge--none {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-detail {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-detail__scope {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-detail__clear {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1677ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-detail__overdue {
|
||||||
|
color: #cf1322;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 创建时间没补到、回退成付款时间时的提示色(带 * 号,tooltip 有说明) */
|
||||||
|
.wst-detail__fallback {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-foot {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏:仓库列表改为两列网格,明细整行下移 */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.wst-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-centers {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wst-centers__list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 2px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,10 +19,16 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent
|
|||||||
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||||
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
||||||
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
|
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 DailyInventory = lazy(() => import('../pages/DailyInventory'));
|
||||||
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
|
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
|
||||||
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
|
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
|
||||||
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
|
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 />;
|
const lazyLoadingElement = <PageLoadingFallback />;
|
||||||
|
|
||||||
@ -115,6 +121,14 @@ const router = createBrowserRouter([
|
|||||||
path: 'planning/operation/mkh-bom-data',
|
path: 'planning/operation/mkh-bom-data',
|
||||||
element: renderLazyComponent(MkhBomData),
|
element: renderLazyComponent(MkhBomData),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/warehouse-ship-timeliness',
|
||||||
|
element: renderLazyComponent(WarehouseShipTimeliness),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/warehouse-logistics-exception',
|
||||||
|
element: renderLazyComponent(WarehouseLogisticsException),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'planning/operation/daily-inventory',
|
path: 'planning/operation/daily-inventory',
|
||||||
element: renderLazyComponent(DailyInventory),
|
element: renderLazyComponent(DailyInventory),
|
||||||
@ -127,6 +141,11 @@ const router = createBrowserRouter([
|
|||||||
path: 'planning/operation/picking-bin-sales-analysis',
|
path: 'planning/operation/picking-bin-sales-analysis',
|
||||||
element: renderLazyComponent(PickingBinSalesAnalysis),
|
element: renderLazyComponent(PickingBinSalesAnalysis),
|
||||||
},
|
},
|
||||||
|
// 缺货补货监控:拣货位缺货 → 从存货位补货
|
||||||
|
{
|
||||||
|
path: 'planning/operation/bin-replenish-monitor',
|
||||||
|
element: renderLazyComponent(BinReplenishMonitor),
|
||||||
|
},
|
||||||
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
|
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
|
||||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user