Compare commits
No commits in common. "main" and "codex/lunanting-bin-replenish-fix-003" have entirely different histories.
main
...
codex/luna
@ -17,7 +17,7 @@ const EXPORT_COLUMNS = [
|
|||||||
{ key: 'pickQty', title: '拣货位库存' },
|
{ key: 'pickQty', title: '拣货位库存' },
|
||||||
{ key: 'storageQty', title: '存储位库存' },
|
{ key: 'storageQty', title: '存储位库存' },
|
||||||
{ key: 'totalQty', title: '现有库存合计' },
|
{ key: 'totalQty', title: '现有库存合计' },
|
||||||
{ key: 'orderQty', title: '待发订单' },
|
{ key: 'orderQty', title: '当日订单' },
|
||||||
{ key: 'excess', title: '超出订单数' },
|
{ key: 'excess', title: '超出订单数' },
|
||||||
{ key: 'suggestQty', title: '建议补货量' },
|
{ key: 'suggestQty', title: '建议补货量' },
|
||||||
{ key: 'shortfall', title: '存储缺口' },
|
{ key: 'shortfall', title: '存储缺口' },
|
||||||
@ -49,7 +49,7 @@ router.get('/export', async (req, res, next) => {
|
|||||||
keyword: req.query.keyword,
|
keyword: req.query.keyword,
|
||||||
status: req.query.status,
|
status: req.query.status,
|
||||||
});
|
});
|
||||||
const fileName = `${payload.warehouse.shortName}待补明细`;
|
const fileName = `${payload.warehouse.shortName}缺货补货监控_${payload.orderDate || ''}`;
|
||||||
|
|
||||||
sendWorkbookDownload(res, {
|
sendWorkbookDownload(res, {
|
||||||
fileName,
|
fileName,
|
||||||
|
|||||||
@ -11,33 +11,12 @@
|
|||||||
* GET /api/warehouse-ship-timeliness/rounds
|
* GET /api/warehouse-ship-timeliness/rounds
|
||||||
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
||||||
*
|
*
|
||||||
* GET /api/warehouse-ship-timeliness/export (2026-10-27 新增)
|
|
||||||
* 「超时未发」明细导出:不分页、不截断,返回本轮全部行(与 /list 同一份缓存)
|
|
||||||
* query: round / force
|
|
||||||
*
|
|
||||||
* ── 两轮核验完成后推钉钉群(2026-10-27 新增)────────────────────────────────
|
|
||||||
* GET /api/warehouse-ship-timeliness/notify/settings 读配置(加签密钥只回掩码)
|
|
||||||
* PUT /api/warehouse-ship-timeliness/notify/settings 保存配置(加签密钥留空 = 不变)
|
|
||||||
* GET /api/warehouse-ship-timeliness/notify/state 上次推送结果
|
|
||||||
* POST /api/warehouse-ship-timeliness/notify/run 立即推送一次(dryRun / probe / force)
|
|
||||||
*
|
|
||||||
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
|
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const {
|
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
|
||||||
getShipTimeliness,
|
|
||||||
listShipTimelinessExportRows,
|
|
||||||
} = require('../services/warehouseShipTimelinessService');
|
|
||||||
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
||||||
const {
|
|
||||||
getShipTimelinessNotifyAdminConfig,
|
|
||||||
getShipTimelinessNotifyRuntimeConfig,
|
|
||||||
loadShipTimelinessNotifyState,
|
|
||||||
missingFields,
|
|
||||||
saveShipTimelinessNotifyConfig,
|
|
||||||
} = require('../services/warehouseShipTimelinessNotifySettings');
|
|
||||||
const { pushShipTimelinessNotify } = require('../jobs/warehouseShipTimelinessNotifyJob');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -80,95 +59,4 @@ router.get('/rounds', async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出「超时未发」明细:**不分页、不截断**,返回本轮考核口径内的全部行。
|
|
||||||
*
|
|
||||||
* query: round(同 /list)、force=1(绕过缓存重查)
|
|
||||||
*
|
|
||||||
* 与 /list 共用 getShipTimeliness(同一份缓存),所以页面刚刷过时这里是秒回。
|
|
||||||
* 前端拿到 rows 后按选中的仓在前端过滤 —— 口径与页面完全一致,不需要第二套 filter。
|
|
||||||
*/
|
|
||||||
router.get('/export', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const data = await listShipTimelinessExportRows(req.query || {});
|
|
||||||
res.json({ success: true, data });
|
|
||||||
} catch (error) {
|
|
||||||
sendError(res, error, '导出超时未发明细失败');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 两轮核验完成后推钉钉群 ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端
|
|
||||||
router.get('/notify/settings', async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const [config, state] = await Promise.all([
|
|
||||||
getShipTimelinessNotifyAdminConfig(),
|
|
||||||
loadShipTimelinessNotifyState(),
|
|
||||||
]);
|
|
||||||
res.json({ success: true, data: { ...config, lastState: state || {} } });
|
|
||||||
} catch (error) {
|
|
||||||
sendError(res, error, '读取发货时效通知配置失败');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存配置;加签密钥留空表示「沿用已存的」,不必每次重填
|
|
||||||
router.put('/notify/settings', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const data = await saveShipTimelinessNotifyConfig(req.body || {});
|
|
||||||
res.json({ success: true, data });
|
|
||||||
} catch (error) {
|
|
||||||
const status = Number(error?.status) || 400;
|
|
||||||
console.error('[WarehouseShipTimeliness] 保存通知配置失败:', error?.message);
|
|
||||||
res.status(status).json({ success: false, error: error.message || '保存发货时效通知配置失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/notify/state', async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const state = await loadShipTimelinessNotifyState();
|
|
||||||
res.json({ success: true, data: state || {} });
|
|
||||||
} catch (error) {
|
|
||||||
sendError(res, error, '读取推送状态失败');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 立即推送一次(页面「立即推送」/「预览正文」/「测试通道」)。
|
|
||||||
* body:
|
|
||||||
* round 'NOON' | 'AFTERNOON',不传按当前时刻自动判定
|
|
||||||
* dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径)
|
|
||||||
* probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通)
|
|
||||||
* force true = 忽略「本轮已推过」(默认手动触发就是 true,除非显式给 false)
|
|
||||||
*/
|
|
||||||
router.post('/notify/run', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const body = req.body || {};
|
|
||||||
const dryRun = body.dryRun === true || body.dryRun === 'true';
|
|
||||||
const probe = body.probe === true || body.probe === 'true';
|
|
||||||
const force = body.force === undefined ? true : (body.force === true || body.force === 'true');
|
|
||||||
if (!dryRun) {
|
|
||||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
|
||||||
const missing = missingFields(config);
|
|
||||||
if (missing.length) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `请先补全并保存:${missing.join(' / ')}`,
|
|
||||||
data: { missingFields: missing },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const result = await pushShipTimelinessNotify({
|
|
||||||
dryRun,
|
|
||||||
probe,
|
|
||||||
force,
|
|
||||||
round: body.round || undefined,
|
|
||||||
});
|
|
||||||
res.json({ success: true, data: result });
|
|
||||||
} catch (error) {
|
|
||||||
sendError(res, error, '推送发货时效通知失败');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -72,9 +72,6 @@ const {
|
|||||||
const {
|
const {
|
||||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||||
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
|
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
|
||||||
const {
|
|
||||||
startShipTimelinessNotifyScheduler,
|
|
||||||
} = require('./jobs/warehouseShipTimelinessNotifyJob');
|
|
||||||
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||||
const { ensureLocalSchema } = require('./services/localSchema');
|
const { ensureLocalSchema } = require('./services/localSchema');
|
||||||
const {
|
const {
|
||||||
@ -801,7 +798,6 @@ async function startServer(options = {}) {
|
|||||||
startAlibaba1688ImageSearchExpiryScheduler();
|
startAlibaba1688ImageSearchExpiryScheduler();
|
||||||
startMkhReplenishAlertScheduler();
|
startMkhReplenishAlertScheduler();
|
||||||
startWarehouseLogisticsExceptionNotifyScheduler();
|
startWarehouseLogisticsExceptionNotifyScheduler();
|
||||||
startShipTimelinessNotifyScheduler();
|
|
||||||
startDailyInventorySnapshotScheduler();
|
startDailyInventorySnapshotScheduler();
|
||||||
|
|
||||||
// Keep the first selection-pool page request from paying the remote schema
|
// Keep the first selection-pool page request from paying the remote schema
|
||||||
|
|||||||
@ -251,15 +251,9 @@ function buildNotifyText(data = {}, options = {}) {
|
|||||||
|
|
||||||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||||||
+ (Number(exclusions.voidedPackages) || 0)
|
+ (Number(exclusions.voidedPackages) || 0)
|
||||||
+ (Number(exclusions.nonRealPackages) || 0)
|
+ (Number(exclusions.nonRealPackages) || 0);
|
||||||
+ (Number(exclusions.transferPackages) || 0);
|
|
||||||
if (excludedCount > 0) {
|
if (excludedCount > 0) {
|
||||||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发,不计考核)`);
|
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
|
||||||
}
|
|
||||||
// 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母
|
|
||||||
const interceptCount = Number(exclusions.interceptPackages) || 0;
|
|
||||||
if (interceptCount > 0) {
|
|
||||||
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||||
|
|||||||
@ -1,679 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 仓储中心 24 小时发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
|
||||||
* 业务口径(2026-10-27 原话):
|
|
||||||
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
|
|
||||||
* 18:00 核验的订单,核验完成后也通知。」
|
|
||||||
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
|
|
||||||
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
|
|
||||||
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
|
|
||||||
* ⚠️ 默认「核验时刻 + 5 分钟」:状态镜像 `query_time` 得晚于核验时刻,
|
|
||||||
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
|
|
||||||
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
|
|
||||||
*
|
|
||||||
* 消息内容:
|
|
||||||
* 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等)
|
|
||||||
* + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。
|
|
||||||
* 明细一行 = 仓|内部单号|线上单号|快递|审核 HH:mm|已等 X 小时(业务对账用)。
|
|
||||||
* (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明)
|
|
||||||
* 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。
|
|
||||||
*
|
|
||||||
* 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。
|
|
||||||
* 推送成功才写状态;发送失败绝不写,否则业务配好通道后反而不推了。
|
|
||||||
*
|
|
||||||
* 时区:生产容器是 UTC,一切「今天 / 现在几点」都用 Asia/Shanghai 判。
|
|
||||||
*/
|
|
||||||
|
|
||||||
const cron = require('node-cron');
|
|
||||||
const {
|
|
||||||
getShipTimeliness,
|
|
||||||
DEFAULT_DETAIL_LIMIT,
|
|
||||||
EXCLUDED_CARRIERS,
|
|
||||||
} = require('../services/warehouseShipTimelinessService');
|
|
||||||
const { ROUNDS, beijingNow, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
|
|
||||||
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
|
||||||
const {
|
|
||||||
DEFAULT_MAX_DETAIL_ROWS,
|
|
||||||
DEFAULT_PAGE_URL,
|
|
||||||
DEFAULT_SEND_TIME_AFTERNOON,
|
|
||||||
DEFAULT_SEND_TIME_NOON,
|
|
||||||
buildPushSlot,
|
|
||||||
getShipTimelinessNotifyRuntimeConfig,
|
|
||||||
isShipTimelinessNotifyReady,
|
|
||||||
loadShipTimelinessNotifyState,
|
|
||||||
saveShipTimelinessNotifyState,
|
|
||||||
trimSentMap,
|
|
||||||
} = require('../services/warehouseShipTimelinessNotifySettings');
|
|
||||||
|
|
||||||
const TIMEZONE = 'Asia/Shanghai';
|
|
||||||
const DEFAULT_TICK_CRON = '* * * * *';
|
|
||||||
// 钉钉 text 上限 20000 字节;留 300 字节给尾巴,避免临界失败
|
|
||||||
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
|
||||||
// 启动补推窗口:后端在 13:05 那一刻没跑(重启 / 部署),30 分钟内起来还补一次
|
|
||||||
const CATCH_UP_WINDOW_MINUTES = 30;
|
|
||||||
|
|
||||||
let tickRunning = false;
|
|
||||||
|
|
||||||
function pad2(value) {
|
|
||||||
return String(value).padStart(2, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 北京时间的 YYYY-MM-DD */
|
|
||||||
function chinaDateKey(date = new Date()) {
|
|
||||||
const base = date instanceof Date ? date : new Date(date);
|
|
||||||
if (!Number.isFinite(base.getTime())) return '';
|
|
||||||
return new Intl.DateTimeFormat('en-CA', {
|
|
||||||
timeZone: TIMEZONE,
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
}).format(base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 北京时间的 HH:mm —— 用来跟配置里的推送时刻比对 */
|
|
||||||
function chinaHourMinute(date = new Date()) {
|
|
||||||
const base = date instanceof Date ? date : new Date(date);
|
|
||||||
if (!Number.isFinite(base.getTime())) return '';
|
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
|
||||||
timeZone: TIMEZONE,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 北京时间的 HH:mm 换算成「当天第几分钟」,方便做 +/- 比较 */
|
|
||||||
function minutesOfDay(date = new Date()) {
|
|
||||||
const text = chinaHourMinute(date);
|
|
||||||
if (!text) return null;
|
|
||||||
const [hour, minute] = text.split(':').map((part) => Number(part));
|
|
||||||
return hour * 60 + minute;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSendTime(text) {
|
|
||||||
const [hour, minute] = String(text || '').split(':').map((part) => Number(part));
|
|
||||||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
|
||||||
return hour * 60 + minute;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 字段清洗:换行 / 连续空白压成单空格(避免一条明细被劈成两行) */
|
|
||||||
function cleanCell(value, fallback = '') {
|
|
||||||
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
|
||||||
return text || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」,与页面同一套写法 */
|
|
||||||
function formatOverdue(minutes) {
|
|
||||||
const m = Number(minutes);
|
|
||||||
if (!Number.isFinite(m) || m <= 0) return '—';
|
|
||||||
if (m < 60) return `${m} 分钟`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
const rest = m % 60;
|
|
||||||
if (h < 24) return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
const hr = h % 24;
|
|
||||||
return hr ? `${d} 天 ${hr} 小时` : `${d} 天`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 审核时间的展示:本轮的日期就只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
|
||||||
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
|
|
||||||
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
|
|
||||||
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
|
|
||||||
*/
|
|
||||||
function auditWhen(auditTime, roundDate) {
|
|
||||||
const text = cleanCell(auditTime);
|
|
||||||
if (!text) return '未审核';
|
|
||||||
const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
|
||||||
if (!hit) {
|
|
||||||
const clock = text.match(/(\d{2}:\d{2})/);
|
|
||||||
return clock ? clock[1] : text;
|
|
||||||
}
|
|
||||||
const [, , month, day, hour, minute] = hit;
|
|
||||||
const sameDay = Boolean(roundDate) && String(roundDate).slice(5, 10) === `${month}-${day}`;
|
|
||||||
return sameDay ? `${hour}:${minute}` : `${month}-${day} ${hour}:${minute}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
|
|
||||||
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
|
||||||
* 两者业务都要,别只留一个。
|
|
||||||
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
|
||||||
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
|
||||||
* 不用再回页面一单单点开确认。
|
|
||||||
* ⚠️ 审核时间跨天要带日期,见 auditWhen 的说明。
|
|
||||||
*/
|
|
||||||
function buildDetailLine(row, index, roundDate) {
|
|
||||||
const parts = [
|
|
||||||
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
|
||||||
cleanCell(row?.oId, '无单号'),
|
|
||||||
cleanCell(row?.soId, '—'),
|
|
||||||
cleanCell(row?.logisticCompany, '—'),
|
|
||||||
`审核 ${auditWhen(row?.auditTime, roundDate)}`,
|
|
||||||
`已等 ${formatOverdue(row?.overdueMinutes)}`,
|
|
||||||
];
|
|
||||||
return `${index}. ${parts.join('|')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按仓分桶(用主仓名),组间按超时单数降序,组内保持服务层原顺序(已按时长降序) */
|
|
||||||
function bucketRowsByCenter(rows) {
|
|
||||||
const buckets = new Map();
|
|
||||||
const firstSeen = new Map();
|
|
||||||
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
|
|
||||||
const key = cleanCell(row?.centerName || row?.warehouseName, '未知仓');
|
|
||||||
if (!buckets.has(key)) {
|
|
||||||
buckets.set(key, []);
|
|
||||||
firstSeen.set(key, index);
|
|
||||||
}
|
|
||||||
buckets.get(key).push(row);
|
|
||||||
});
|
|
||||||
return Array.from(buckets.entries())
|
|
||||||
.map(([key, items]) => ({ key, items, index: firstSeen.get(key) }))
|
|
||||||
.sort((a, b) => b.items.length - a.items.length || a.index - b.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按仓分配明细额度,返回已按仓聚集的行。
|
|
||||||
* 同 WLE 的理由:某仓超时特别多时会一口气吃满额度,其它仓一条都看不到。
|
|
||||||
* ① 仓数 ≥ 额度 → 每仓 1 条;
|
|
||||||
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法)。
|
|
||||||
*/
|
|
||||||
function pickDetailRows(rows, limit) {
|
|
||||||
const groups = bucketRowsByCenter(rows);
|
|
||||||
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
|
|
||||||
const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total));
|
|
||||||
|
|
||||||
if (quota === 0) return [];
|
|
||||||
if (quota >= total) return groups.flatMap((group) => group.items);
|
|
||||||
if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]);
|
|
||||||
|
|
||||||
const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 }));
|
|
||||||
const remaining = quota - groups.length;
|
|
||||||
const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0);
|
|
||||||
|
|
||||||
if (remaining > 0 && leftTotal > 0) {
|
|
||||||
alloc.forEach((item) => {
|
|
||||||
item.exact = (item.left / leftTotal) * remaining;
|
|
||||||
item.extra = Math.min(item.left, Math.floor(item.exact));
|
|
||||||
});
|
|
||||||
let assigned = alloc.reduce((sum, item) => sum + item.extra, 0);
|
|
||||||
[...alloc]
|
|
||||||
.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))
|
|
||||||
.forEach((item) => {
|
|
||||||
if (assigned >= remaining || item.extra >= item.left) return;
|
|
||||||
item.extra += 1;
|
|
||||||
assigned += 1;
|
|
||||||
});
|
|
||||||
alloc.forEach((item) => {
|
|
||||||
item.take += item.extra;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return alloc.flatMap((item) => item.group.items.slice(0, item.take));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按仓汇总块:只列超时 > 0 的仓,降序,最多 12 行 */
|
|
||||||
function buildCenterLines(data = {}) {
|
|
||||||
return (data.centers || [])
|
|
||||||
.map((center) => ({
|
|
||||||
name: cleanCell(center.name || center.key, '未知仓'),
|
|
||||||
overdue: Number(center.overdueUnshipped) || 0,
|
|
||||||
oldestDays: Number(center.oldestDays) || 0,
|
|
||||||
}))
|
|
||||||
.filter((item) => item.overdue > 0)
|
|
||||||
.sort((a, b) => b.overdue - a.overdue)
|
|
||||||
.slice(0, 12)
|
|
||||||
.map((item, index) => (
|
|
||||||
`${index + 1}. ${item.name} ${item.overdue} 单`
|
|
||||||
+ (item.oldestDays > 0 ? `(最久 ${item.oldestDays} 天)` : '')
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPageLink(pageUrl, roundKey) {
|
|
||||||
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
|
||||||
if (!roundKey) return base;
|
|
||||||
const glue = base.includes('?') ? '&' : '?';
|
|
||||||
return `${base}${glue}round=${encodeURIComponent(roundKey)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 组装群消息正文(纯函数,便于单测)。
|
|
||||||
*
|
|
||||||
* @param {object} data getShipTimeliness 的返回
|
|
||||||
* @param {object} options maxDetailRows / pageUrl / roundKey
|
|
||||||
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
|
|
||||||
*/
|
|
||||||
function buildNotifyText(data = {}, options = {}) {
|
|
||||||
const summary = data.summary || {};
|
|
||||||
const round = data.round || {};
|
|
||||||
const window = data.window || {};
|
|
||||||
const rows = Array.isArray(data.orders) ? data.orders : [];
|
|
||||||
const freshness = data.freshness || {};
|
|
||||||
|
|
||||||
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
|
|
||||||
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
|
|
||||||
: DEFAULT_MAX_DETAIL_ROWS;
|
|
||||||
const roundKey = options.roundKey || round.key || '';
|
|
||||||
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, roundKey);
|
|
||||||
|
|
||||||
const overdue = Number(summary.overdueUnshipped) || 0;
|
|
||||||
const centerCount = Number(summary.overdueCenterCount) || 0;
|
|
||||||
const oldestDays = Number(summary.oldestDays) || 0;
|
|
||||||
const heldCount = Number(summary.heldCount) || 0;
|
|
||||||
const roundLabel = cleanCell(round.label, '本轮核验');
|
|
||||||
const windowEnd = String(window.end || '').slice(11, 16);
|
|
||||||
|
|
||||||
const head = [
|
|
||||||
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前审核` : ''}`,
|
|
||||||
`超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓`
|
|
||||||
+ (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (overdue === 0) {
|
|
||||||
head.push('本轮没有超时未发货的订单,全部按时发出。');
|
|
||||||
}
|
|
||||||
if (heldCount > 0) {
|
|
||||||
head.push(`另有 ${heldCount} 单不计考核(已支付待审核 / 订单异常)`);
|
|
||||||
}
|
|
||||||
const carrierSkipped = Number(summary.carrierSkipped) || 0;
|
|
||||||
if (carrierSkipped > 0) {
|
|
||||||
// 快递清单由服务层给(`excludedCarriers`),别在这里写死 ——
|
|
||||||
// 2026-10-27 加「武进安能」时就吃过一次:写死的文案忘了改,群里口径和页面不一致。
|
|
||||||
const carriers = (Array.isArray(data.excludedCarriers) ? data.excludedCarriers : EXCLUDED_CARRIERS)
|
|
||||||
.filter(Boolean);
|
|
||||||
head.push(`已剔除 ${carrierSkipped} 单不考核快递${carriers.length ? `(${carriers.join(' / ')})` : ''}`);
|
|
||||||
}
|
|
||||||
// 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单
|
|
||||||
if (freshness.coversDeadline === false) {
|
|
||||||
head.push(
|
|
||||||
`⚠️ 数据快照只到 ${String(freshness.stateMaxQuery || '').slice(11, 16) || '未知时间'},`
|
|
||||||
+ `尚未覆盖本轮核验时刻 ${String(data.deadline || '').slice(11, 16)},超时单量偏大,请以聚水潭实时状态为准。`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const centerLines = overdue > 0 ? buildCenterLines(data) : [];
|
|
||||||
const roundDate = String(data.date || round.date || '').slice(0, 10);
|
|
||||||
const detailAll = pickDetailRows(rows, maxDetailRows)
|
|
||||||
.map((row, index) => buildDetailLine(row, index + 1, roundDate));
|
|
||||||
const wantCount = detailAll.length;
|
|
||||||
|
|
||||||
const buildTail = (listed) => {
|
|
||||||
const lines = [];
|
|
||||||
if (overdue > 0) {
|
|
||||||
if (listed === 0) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push(`明细共 ${overdue} 条,消息里不放,请点下面链接查看。`);
|
|
||||||
} else if (overdue > listed) {
|
|
||||||
lines.push(`…其余 ${overdue - listed} 条见页面。`);
|
|
||||||
}
|
|
||||||
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
};
|
|
||||||
|
|
||||||
const assemble = (listed, withCenters) => {
|
|
||||||
const blocks = [...head];
|
|
||||||
if (withCenters && centerLines.length) {
|
|
||||||
blocks.push('');
|
|
||||||
blocks.push('按仓(超时单):');
|
|
||||||
blocks.push(...centerLines);
|
|
||||||
}
|
|
||||||
if (listed > 0) {
|
|
||||||
blocks.push('');
|
|
||||||
blocks.push(`明细前 ${listed} 条:`);
|
|
||||||
blocks.push(...detailAll.slice(0, listed));
|
|
||||||
}
|
|
||||||
const tail = buildTail(listed);
|
|
||||||
if (tail.length) blocks.push(...tail);
|
|
||||||
const text = blocks.join('\n');
|
|
||||||
return { text, bytes: Buffer.byteLength(text, 'utf8') };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 超长就从明细末尾往前砍(先砍明细,再砍分仓块)
|
|
||||||
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」
|
|
||||||
let listed = wantCount;
|
|
||||||
let withCenters = centerLines.length > 0;
|
|
||||||
let result = assemble(listed, withCenters);
|
|
||||||
let truncated = false;
|
|
||||||
|
|
||||||
while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) {
|
|
||||||
listed -= 1;
|
|
||||||
truncated = true;
|
|
||||||
result = assemble(listed, withCenters);
|
|
||||||
}
|
|
||||||
if (result.bytes > SAFE_CONTENT_BYTES && withCenters) {
|
|
||||||
withCenters = false;
|
|
||||||
result = assemble(listed, withCenters);
|
|
||||||
}
|
|
||||||
if (result.bytes > SAFE_CONTENT_BYTES) {
|
|
||||||
const chars = Array.from(result.text);
|
|
||||||
let bytes = 0;
|
|
||||||
let cut = '';
|
|
||||||
for (const char of chars) {
|
|
||||||
const size = Buffer.byteLength(char, 'utf8');
|
|
||||||
if (bytes + size > SAFE_CONTENT_BYTES) break;
|
|
||||||
bytes += size;
|
|
||||||
cut += char;
|
|
||||||
}
|
|
||||||
result = { text: cut, bytes };
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: result.text,
|
|
||||||
rowCount: overdue,
|
|
||||||
listedCount: listed,
|
|
||||||
bytes: result.bytes,
|
|
||||||
truncated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取某一轮次今天的元信息(deadline / windowEnd / label) */
|
|
||||||
function resolveRoundMeta(roundKey, now = new Date()) {
|
|
||||||
const key = String(roundKey || '').toUpperCase();
|
|
||||||
const target = ROUNDS.find((round) => round.key === key);
|
|
||||||
if (!target) return null;
|
|
||||||
const described = describeRounds(now).find((round) => round.key === key);
|
|
||||||
if (!described) return null;
|
|
||||||
return {
|
|
||||||
key: target.key,
|
|
||||||
label: described.label || target.label,
|
|
||||||
date: described.date,
|
|
||||||
verifyHour: target.verifyHour,
|
|
||||||
deadline: described.deadline,
|
|
||||||
windowEnd: described.windowEnd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 某个轮次今天该在几点推(读配置) */
|
|
||||||
function sendTimeForRound(config, roundKey) {
|
|
||||||
const key = String(roundKey || '').toUpperCase();
|
|
||||||
if (key === 'AFTERNOON') return config.afternoonSendTime || DEFAULT_SEND_TIME_AFTERNOON;
|
|
||||||
return config.noonSendTime || DEFAULT_SEND_TIME_NOON;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行一次推送。参数:
|
|
||||||
* round 'NOON' | 'AFTERNOON'(不传 = 按当前时刻自动判定)
|
|
||||||
* now 指定时间(测试用)
|
|
||||||
* force 忽略「今天这轮已推过」(页面「立即推送」用)
|
|
||||||
* dryRun 只组装消息不发送(预览 / 单测用)
|
|
||||||
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
|
||||||
*/
|
|
||||||
async function pushShipTimelinessNotify(options = {}) {
|
|
||||||
// 🔥 两个「现在」不能混用(生产容器 /etc/timezone = Etc/UTC):
|
|
||||||
// · rawNow —— 真实时刻。凡是走 Intl(Asia/Shanghai) 或 toISOString 的地方用它;
|
|
||||||
// · bjNow —— 平移成「本地 getter 读出来正好是北京时间」的 Date。
|
|
||||||
// registry.describeRounds / resolveRoundMeta / getShipTimeliness 内部都用
|
|
||||||
// `getDate()/getHours()` 直接读,必须喂 bjNow —— 否则北京 00:00~08:00 会取到前一天
|
|
||||||
// (2026-10-21 生产踩过:13:00 轮算出 3895 单,实际只有 6 单)。
|
|
||||||
const rawNow = options.now === undefined ? new Date() : new Date(options.now);
|
|
||||||
const bjNow = beijingNow(rawNow);
|
|
||||||
const pushDate = chinaDateKey(rawNow);
|
|
||||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
|
||||||
|
|
||||||
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
|
||||||
if (options.probe === true) {
|
|
||||||
if (!isShipTimelinessNotifyReady(config)) {
|
|
||||||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
|
||||||
}
|
|
||||||
const delivery = await sendTextToWebhook({
|
|
||||||
webhookUrl: config.webhookUrl,
|
|
||||||
secret: config.webhookSecret,
|
|
||||||
content: '【仓储中心发货时效】通知通道测试:收到这条说明 webhook 配置可用。',
|
|
||||||
});
|
|
||||||
return { sent: true, probe: true, delivery };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 轮次:显式指定优先;否则按当前时刻自动判定(13:00 前 = 没有轮次)
|
|
||||||
const explicitRound = String(options.round || '').toUpperCase();
|
|
||||||
let roundKey = explicitRound;
|
|
||||||
if (!roundKey) {
|
|
||||||
const minutes = minutesOfDay(rawNow);
|
|
||||||
let active = null;
|
|
||||||
for (const round of ROUNDS) {
|
|
||||||
if (minutes >= round.verifyHour * 60) active = round;
|
|
||||||
}
|
|
||||||
if (!active) return { sent: false, reason: 'not_started' };
|
|
||||||
roundKey = active.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = resolveRoundMeta(roundKey, bjNow);
|
|
||||||
if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey };
|
|
||||||
|
|
||||||
// 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick)
|
|
||||||
const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16));
|
|
||||||
if (deadlineMinutes != null && minutesOfDay(rawNow) < deadlineMinutes) {
|
|
||||||
return { sent: false, reason: 'before_deadline', round: roundKey, deadline: meta.deadline };
|
|
||||||
}
|
|
||||||
|
|
||||||
const slot = buildPushSlot(pushDate, roundKey);
|
|
||||||
const state = await loadShipTimelinessNotifyState();
|
|
||||||
const sentMap = (state && typeof state.sent === 'object' && state.sent) ? state.sent : {};
|
|
||||||
if (sentMap[slot] && options.force !== true) {
|
|
||||||
return {
|
|
||||||
sent: false,
|
|
||||||
reason: 'already_sent',
|
|
||||||
slot,
|
|
||||||
lastSentAt: sentMap[slot].sentAt || null,
|
|
||||||
round: roundKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!config.enabled && options.force !== true) {
|
|
||||||
return { sent: false, reason: 'disabled', round: roundKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
const dryRun = options.dryRun === true;
|
|
||||||
if (!dryRun && !isShipTimelinessNotifyReady(config)) {
|
|
||||||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 明细条数用默认 500(= 页面同一档),这样与页面共用结果缓存:
|
|
||||||
// 推送完业务点进页面几乎是热的,不会为了推一条消息额外扫一次订单视图。
|
|
||||||
// summary/centers 都是全量算的,limit 只截断明细,不影响数字。
|
|
||||||
const data = await getShipTimeliness({
|
|
||||||
round: roundKey,
|
|
||||||
limit: DEFAULT_DETAIL_LIMIT,
|
|
||||||
now: bjNow,
|
|
||||||
});
|
|
||||||
if (data?.notStarted) {
|
|
||||||
return { sent: false, reason: 'not_started', round: roundKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
const built = buildNotifyText(data, {
|
|
||||||
maxDetailRows: config.maxDetailRows,
|
|
||||||
pageUrl: config.pageUrl,
|
|
||||||
roundKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
return {
|
|
||||||
sent: false,
|
|
||||||
reason: 'dry_run',
|
|
||||||
round: roundKey,
|
|
||||||
roundLabel: data.round?.label || meta.label,
|
|
||||||
deadline: data.deadline || meta.deadline,
|
|
||||||
text: built.text,
|
|
||||||
rowCount: built.rowCount,
|
|
||||||
listedCount: built.listedCount,
|
|
||||||
bytes: built.bytes,
|
|
||||||
truncated: built.truncated,
|
|
||||||
summary: data.summary || null,
|
|
||||||
freshness: data.freshness || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const delivery = await sendTextToWebhook({
|
|
||||||
webhookUrl: config.webhookUrl,
|
|
||||||
secret: config.webhookSecret,
|
|
||||||
content: built.text,
|
|
||||||
atAll: config.atAll === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextSent = {
|
|
||||||
...sentMap,
|
|
||||||
[slot]: {
|
|
||||||
sentAt: rawNow.toISOString(),
|
|
||||||
rowCount: built.rowCount,
|
|
||||||
listedCount: built.listedCount,
|
|
||||||
bytes: built.bytes,
|
|
||||||
deadline: data.deadline || meta.deadline || '',
|
|
||||||
roundLabel: data.round?.label || meta.label || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await saveShipTimelinessNotifyState({
|
|
||||||
version: 1,
|
|
||||||
sent: trimSentMap(nextSent),
|
|
||||||
lastSentAt: rawNow.toISOString(),
|
|
||||||
lastStatus: 'sent',
|
|
||||||
lastError: '',
|
|
||||||
lastRound: roundKey,
|
|
||||||
lastRoundLabel: data.round?.label || meta.label || '',
|
|
||||||
lastSlot: slot,
|
|
||||||
rowCount: built.rowCount,
|
|
||||||
listedCount: built.listedCount,
|
|
||||||
bytes: built.bytes,
|
|
||||||
truncated: built.truncated,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sent: true,
|
|
||||||
round: roundKey,
|
|
||||||
roundLabel: data.round?.label || meta.label,
|
|
||||||
slot,
|
|
||||||
rowCount: built.rowCount,
|
|
||||||
listedCount: built.listedCount,
|
|
||||||
bytes: built.bytes,
|
|
||||||
truncated: built.truncated,
|
|
||||||
text: built.text,
|
|
||||||
summary: data.summary || null,
|
|
||||||
delivery,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runShipTimelinessNotifyJob(source = 'scheduled', options = {}) {
|
|
||||||
try {
|
|
||||||
const result = await pushShipTimelinessNotify(options);
|
|
||||||
if (result.sent) {
|
|
||||||
console.log(
|
|
||||||
`[WstNotify] ${source} 已推送 ${result.round}(超时 ${result.rowCount} 单,列出 ${result.listedCount} 条,${result.bytes} 字节)`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(`[WstNotify] ${source} 跳过: ${result.reason}${result.round ? ` (${result.round})` : ''}`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[WstNotify] ${source} 执行失败:`, error.message || error);
|
|
||||||
try {
|
|
||||||
await saveShipTimelinessNotifyState({
|
|
||||||
...(await loadShipTimelinessNotifyState()),
|
|
||||||
lastError: String(error.message || error).slice(0, 500),
|
|
||||||
lastFailedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (_ignored) { /* 状态写失败不影响主流程 */ }
|
|
||||||
return { sent: false, reason: 'error', error: error.message || String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断此刻该推哪些轮次(返回数组 —— 两个时刻万一配成同一个 HH:mm,两轮都要推)。
|
|
||||||
* · 精确命中 HH:mm → 推
|
|
||||||
* · 刚启动的补推窗口(sendTime ~ sendTime+30min 内)→ 推(是否重复由调用方按 slot 去重)
|
|
||||||
* 之所以不用固定 cron:业务在页面上改时刻后要立即生效,不能等重启。
|
|
||||||
*/
|
|
||||||
function resolveDueRounds(config, now = new Date(), { catchUp = false } = {}) {
|
|
||||||
const minutes = minutesOfDay(now);
|
|
||||||
if (minutes == null) return [];
|
|
||||||
|
|
||||||
const due = [];
|
|
||||||
for (const round of ROUNDS) {
|
|
||||||
const target = parseSendTime(sendTimeForRound(config, round.key));
|
|
||||||
if (target == null) continue;
|
|
||||||
if (minutes === target) {
|
|
||||||
due.push(round.key);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (catchUp && minutes > target && minutes <= target + CATCH_UP_WINDOW_MINUTES) {
|
|
||||||
due.push(round.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return due;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调度:每分钟 tick,比对两个轮次各自的推送时刻。
|
|
||||||
* 启动时额外做一次「补推」检查:13:05 那一刻后端没在跑(重启 / 部署)也能补上。
|
|
||||||
*/
|
|
||||||
function startShipTimelinessNotifyScheduler() {
|
|
||||||
const disabled = String(process.env.DISABLE_WST_NOTIFY || '').trim().toLowerCase() === 'true';
|
|
||||||
if (disabled) {
|
|
||||||
console.log('[Scheduler] 发货时效钉钉推送已禁用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const expression = String(process.env.WST_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
|
||||||
|
|
||||||
const tick = async ({ catchUp = false } = {}) => {
|
|
||||||
if (tickRunning) return;
|
|
||||||
tickRunning = true;
|
|
||||||
try {
|
|
||||||
const now = new Date();
|
|
||||||
const config = await getShipTimelinessNotifyRuntimeConfig();
|
|
||||||
if (!config.enabled) return;
|
|
||||||
const due = resolveDueRounds(config, now, { catchUp });
|
|
||||||
if (!due.length) return;
|
|
||||||
|
|
||||||
const state = catchUp ? await loadShipTimelinessNotifyState() : null;
|
|
||||||
for (const roundKey of due) {
|
|
||||||
if (catchUp) {
|
|
||||||
// 补推只在「今天这轮确实还没推过」时才动手,避免重启就把消息重发一遍
|
|
||||||
const slot = buildPushSlot(chinaDateKey(now), roundKey);
|
|
||||||
if (state?.sent && state.sent[slot]) continue;
|
|
||||||
console.log(`[WstNotify] 启动补推检查命中 ${roundKey}`);
|
|
||||||
}
|
|
||||||
await runShipTimelinessNotifyJob(catchUp ? 'catch_up' : 'scheduled', { now, round: roundKey });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[WstNotify] 定时检查失败:', error.message || error);
|
|
||||||
} finally {
|
|
||||||
tickRunning = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
cron.schedule(expression, () => { tick(); }, { timezone: TIMEZONE });
|
|
||||||
|
|
||||||
// 启动后 45s 跑一次补推检查(等业务库/隧道就绪,别和启动预热抢)
|
|
||||||
setTimeout(() => { tick({ catchUp: true }); }, 45 * 1000);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Scheduler] 发货时效钉钉推送已注册: 每分钟检查,13:00 轮→默认 13:05、18:00 轮→默认 18:05 (${TIMEZONE})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
CATCH_UP_WINDOW_MINUTES,
|
|
||||||
DEFAULT_TICK_CRON,
|
|
||||||
SAFE_CONTENT_BYTES,
|
|
||||||
TIMEZONE,
|
|
||||||
auditWhen,
|
|
||||||
buildCenterLines,
|
|
||||||
buildDetailLine,
|
|
||||||
buildNotifyText,
|
|
||||||
buildPageLink,
|
|
||||||
bucketRowsByCenter,
|
|
||||||
chinaDateKey,
|
|
||||||
chinaHourMinute,
|
|
||||||
cleanCell,
|
|
||||||
formatOverdue,
|
|
||||||
minutesOfDay,
|
|
||||||
parseSendTime,
|
|
||||||
pickDetailRows,
|
|
||||||
pushShipTimelinessNotify,
|
|
||||||
resolveDueRounds,
|
|
||||||
resolveRoundMeta,
|
|
||||||
runShipTimelinessNotifyJob,
|
|
||||||
sendTimeForRound,
|
|
||||||
startShipTimelinessNotifyScheduler,
|
|
||||||
};
|
|
||||||
@ -4,7 +4,7 @@
|
|||||||
* 缺货补货监控(订单驱动版,2026-10-22 重写)
|
* 缺货补货监控(订单驱动版,2026-10-22 重写)
|
||||||
* ==========================================================================
|
* ==========================================================================
|
||||||
* 业务需求(用户确认):
|
* 业务需求(用户确认):
|
||||||
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够待发订单拣,
|
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣,
|
||||||
* 需要从「存储库位」把货补过去。
|
* 需要从「存储库位」把货补过去。
|
||||||
*
|
*
|
||||||
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
|
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
|
||||||
@ -14,26 +14,21 @@
|
|||||||
*
|
*
|
||||||
* ── 数据源 ───────────────────────────────────────────────────────────
|
* ── 数据源 ───────────────────────────────────────────────────────────
|
||||||
* 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
|
* 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
|
||||||
* 订单:rpt.op_OrderNotDispatched(聚水潭「打单拣货/未发货」每日快照,SKU 级、组合装已展开)
|
* 订单:rpt.jst_Orders × jst_OrderItem(线上店铺)
|
||||||
* —— 直接对齐用户在聚水潭「打单拣货 → 简尚崔桥工厂仓」看到的单据。
|
* + rpt.jst_qmcust_orderlist × jst_qmcust_orderlist_item(全民客户 B2B)
|
||||||
|
* + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件
|
||||||
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
|
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
|
||||||
* 按 wh_id 会把多仓混成一个桶。
|
* 按 wh_id 会把多仓混成一个桶。
|
||||||
*
|
* 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」,
|
||||||
* ── 待发口径(用户 2026-10-22 三次明确:「取打单拣货里的数据」)──────────
|
* 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。
|
||||||
* 补货需求只来自「聚水潭打单拣货 / 发货中」的单据,**已发(Sent)不参与**。
|
|
||||||
* 数据源 rpt.op_OrderNotDispatched 是**每日快照**(一天一批,local_updated≈07:00):
|
|
||||||
* 取该仓 **最新快照日** 且 status='发货中'(历史标签曾为「发货中(打单拣货)」)的行,
|
|
||||||
* 按 sku_id 汇总 qty —— 即当前待拣/待发的订单量(组合装已展开为子件,带 src_combine_sku_id)。
|
|
||||||
* **不含**:已发(Sent)/取消/删除/拆单/合单、异常(Question/异常)、已付款待审核、待付款。
|
|
||||||
* (异常挂起单不保证近期发货,不计入补货需求;如需要可再把「异常」并入。)
|
|
||||||
*
|
*
|
||||||
* ── 判断逻辑(核心)──────────────────────────────────────────────────
|
* ── 判断逻辑(核心)──────────────────────────────────────────────────
|
||||||
* 对每一个 SKU(在拣货/存储位上有库存):
|
* 对每一个 SKU(在拣货/存储位上有库存):
|
||||||
* 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
|
* 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
|
||||||
* 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
|
* 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
|
||||||
* 待发订单 orderQty = 该 SKU 的「打单拣货/发货中」快照待发量(组合装已展开)
|
* 当日订单 orderQty = 该 SKU 在订单日期当天的订单量(含组合装展开)
|
||||||
* 超出订单数 excess = max(0, orderQty − pickQty)
|
* 超出订单数 excess = max(0, orderQty − pickQty)
|
||||||
* —— 拣货位不够待发订单拣,超出的这部分要从存储位补到拣货位
|
* —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位
|
||||||
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
|
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
|
||||||
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
|
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
|
||||||
*
|
*
|
||||||
@ -41,10 +36,10 @@
|
|||||||
* short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
|
* short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
|
||||||
* need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
|
* need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
|
||||||
* enough 拣货位充足(0 < orderQty ≤ pickQty)
|
* enough 拣货位充足(0 < orderQty ≤ pickQty)
|
||||||
* no_order 无待发单(orderQty = 0)
|
* no_order 今日无单(orderQty = 0)
|
||||||
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
|
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
|
||||||
*
|
*
|
||||||
* ⚠️ 性能:库存视图聚合 30s~1min;待发快照表(op_OrderNotDispatched)查询较快;
|
* ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min,
|
||||||
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
|
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -80,14 +75,6 @@ const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
|
|||||||
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
|
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
|
||||||
const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
|
const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
|
||||||
|
|
||||||
// ── 待发口径:聚水潭「打单拣货/发货中」快照(rpt.op_OrderNotDispatched)────────
|
|
||||||
// 该表是每日快照;取该仓「最新快照日」且 status='发货中' 的行。
|
|
||||||
// 历史标签在 2026-03 之前叫「发货中(打单拣货)」,两个都认,避免换名漏数。
|
|
||||||
// 不含:已发/取消/删除/拆单/合单、异常、已付款待审核、待付款。
|
|
||||||
const PENDING_STATUS_LIST = Object.freeze(['发货中', '发货中(打单拣货)']);
|
|
||||||
const PENDING_STATUS_IN = PENDING_STATUS_LIST.map((s) => `'${s}'`).join(',');
|
|
||||||
const PENDING_ORDER_SCOPE = '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)';
|
|
||||||
|
|
||||||
const STATUS = Object.freeze({
|
const STATUS = Object.freeze({
|
||||||
SHORT: 'short',
|
SHORT: 'short',
|
||||||
NEED: 'need',
|
NEED: 'need',
|
||||||
@ -99,7 +86,7 @@ const STATUS_LABELS = Object.freeze({
|
|||||||
short: '存储不足',
|
short: '存储不足',
|
||||||
need: '需补货',
|
need: '需补货',
|
||||||
enough: '拣货位充足',
|
enough: '拣货位充足',
|
||||||
no_order: '无待发单',
|
no_order: '今日无单',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 排序权重:最紧急的排最前
|
// 排序权重:最紧急的排最前
|
||||||
@ -138,6 +125,14 @@ function resolveCacheTtlMs() {
|
|||||||
return num;
|
return num;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 订单日期的窗口边界(仅用日期字符串,避免时区问题;副本 send_date 按中国营业日存储) */
|
||||||
|
function orderWindow(orderDate) {
|
||||||
|
const d = new Date(`${orderDate}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + 1);
|
||||||
|
const next = d.toISOString().slice(0, 10);
|
||||||
|
return { shipFrom: `${orderDate} 00:00:00`, shipTo: `${next} 00:00:00` };
|
||||||
|
}
|
||||||
|
|
||||||
// ── 副本取数 ────────────────────────────────────────────────────────
|
// ── 副本取数 ────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
|
* 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
|
||||||
@ -155,79 +150,89 @@ async function loadReplicaInventory(warehouse) {
|
|||||||
FROM rpt.jst_packInventoryInfo p
|
FROM rpt.jst_packInventoryInfo p
|
||||||
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
|
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
|
||||||
WHERE p.wms_co_id = @wmsCoId
|
WHERE p.wms_co_id = @wmsCoId
|
||||||
AND p.wh_id = 1
|
|
||||||
AND ISNULL(p.bin, '') <> ''
|
AND ISNULL(p.bin, '') <> ''
|
||||||
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
|
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
|
||||||
GROUP BY p.sku_id`;
|
GROUP BY p.sku_id`;
|
||||||
// 单仓按货位前缀聚合约 1.7s;走 querySlow(独立连接 + 池级超时)避免 60s 池超时误杀。
|
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 });
|
||||||
return cloudDb.querySlow(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 300000 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 该仓最近的订单日期(线上 + 全民客户 取最大 send_date 日期) */
|
||||||
* 批量取 SKU 主数据(品名 / 供应商),用于补齐「有订单但无货位库存」的 SKU 名称。
|
async function loadOrderDate(warehouse) {
|
||||||
* 这批 SKU 不在库存视图里,但确实有打单拣货待发单,必须进待补明细。
|
const rows = await cloudDb.query(
|
||||||
*/
|
`SELECT MAX(d) AS d FROM (
|
||||||
async function loadSkuNames(skuIds) {
|
SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
|
||||||
const uniq = [...new Set((skuIds || []).filter(Boolean).map((id) => toText(id, 120)))];
|
FROM rpt.jst_Orders WHERE wms_co_id = @wmsCoId
|
||||||
if (!uniq.length) return new Map();
|
UNION ALL
|
||||||
const map = new Map();
|
SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
|
||||||
const CHUNK = 400;
|
FROM rpt.jst_qmcust_orderlist WHERE wms_co_id = @wmsCoId
|
||||||
for (let i = 0; i < uniq.length; i += CHUNK) {
|
) t`,
|
||||||
const chunk = uniq.slice(i, i + CHUNK);
|
|
||||||
const params = {};
|
|
||||||
const ph = chunk.map((_, idx) => `@s${idx}`).join(',');
|
|
||||||
chunk.forEach((id, idx) => { params[`s${idx}`] = id; });
|
|
||||||
const rows = await cloudDb.querySlow(
|
|
||||||
`SELECT sku_id AS skuId, name AS productName, supplier_name AS supplier
|
|
||||||
FROM rpt.jst_sku WHERE sku_id IN (${ph})`,
|
|
||||||
params,
|
|
||||||
{ requestTimeout: 120000 }
|
|
||||||
);
|
|
||||||
(rows || []).forEach((r) => {
|
|
||||||
const id = toText(r.skuId, 120);
|
|
||||||
if (id) map.set(id, { productName: toText(r.productName, 300), supplier: toText(r.supplier, 120) });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 各 SKU 的「待发」订单量:直接取聚水潭打单拣货快照表 rpt.op_OrderNotDispatched。
|
|
||||||
* 该表 SKU 级、组合装已展开为子件(src_combine_sku_id),qty 即该子件待发件数。
|
|
||||||
* 取「该仓最新快照日」+ status IN ('发货中','发货中(打单拣货)'),按 sku_id 汇总。
|
|
||||||
*
|
|
||||||
* ⚠️ 性能红线:副本库 mssql 池默认 60s requestTimeout,CTE+CROSS JOIN 写法实测 60s
|
|
||||||
* 超时失败。改成**两步 querySlow**(先取最新快照日,再按日范围汇总),实测 ~12s,
|
|
||||||
* 走独立连接 + 池级 requestTimeout(5 分钟),必须用它。
|
|
||||||
*/
|
|
||||||
async function loadPendingOrderMap(warehouse) {
|
|
||||||
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
|
|
||||||
const slowOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 300000 };
|
|
||||||
|
|
||||||
// step1:该仓最新快照日(local_updated≈每日 07:00 一批,MAX 落在最新那批)
|
|
||||||
const snapRows = await runQuery(
|
|
||||||
`SELECT CAST(MAX(local_updated) AS date) AS snapDate
|
|
||||||
FROM rpt.op_OrderNotDispatched
|
|
||||||
WHERE wms_co_id = @wmsCoId`,
|
|
||||||
{ wmsCoId: warehouse.wmsCoId },
|
{ wmsCoId: warehouse.wmsCoId },
|
||||||
slowOpts
|
{ requestTimeout: 120000 }
|
||||||
);
|
);
|
||||||
const snapDate = snapRows && snapRows[0] && snapRows[0].snapDate;
|
return (rows && rows[0] && rows[0].d) || null;
|
||||||
if (!snapDate) return new Map();
|
}
|
||||||
|
|
||||||
// step2:最新快照日当天的「发货中」待发量,按 sku_id 汇总(组合装已展开)
|
/** 当日(订单日期)各 SKU 的订单量:4 个来源分支,组合装展开为子件。 */
|
||||||
|
async function loadTodayOrderMap(warehouse, orderDate) {
|
||||||
|
if (!orderDate) return new Map();
|
||||||
|
const { shipFrom, shipTo } = orderWindow(orderDate);
|
||||||
|
const sql = `
|
||||||
|
WITH ship AS (
|
||||||
|
-- 1) 线上店铺订单:普通商品行
|
||||||
|
SELECT oi.sku_id AS skuId,
|
||||||
|
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||||||
|
FROM rpt.jst_OrderItem oi
|
||||||
|
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||||
|
WHERE o.wms_co_id = @wmsCoId
|
||||||
|
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
|
||||||
|
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||||
|
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
|
||||||
|
GROUP BY oi.sku_id
|
||||||
|
UNION ALL
|
||||||
|
-- 2) 线上店铺订单:组合装行 → 展开为子件
|
||||||
|
SELECT ci.src_sku_id AS skuId,
|
||||||
|
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||||||
|
FROM rpt.jst_OrderItem oi
|
||||||
|
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||||
|
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
|
||||||
|
WHERE o.wms_co_id = @wmsCoId
|
||||||
|
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
|
||||||
|
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||||
|
AND oi.sku_type = 'combine'
|
||||||
|
GROUP BY ci.src_sku_id
|
||||||
|
UNION ALL
|
||||||
|
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
|
||||||
|
SELECT oi.sku_id AS skuId,
|
||||||
|
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||||||
|
FROM rpt.jst_qmcust_orderlist_item oi
|
||||||
|
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
|
||||||
|
WHERE o.wms_co_id = @wmsCoId
|
||||||
|
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
|
||||||
|
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
|
||||||
|
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
|
||||||
|
GROUP BY oi.sku_id
|
||||||
|
UNION ALL
|
||||||
|
-- 4) 全民客户订单:组合装行 → 展开为子件
|
||||||
|
SELECT ci.src_sku_id AS skuId,
|
||||||
|
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||||||
|
FROM rpt.jst_qmcust_orderlist_item oi
|
||||||
|
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
|
||||||
|
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
|
||||||
|
WHERE o.wms_co_id = @wmsCoId
|
||||||
|
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
|
||||||
|
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
|
||||||
|
AND oi.sku_type = 'combine'
|
||||||
|
GROUP BY ci.src_sku_id
|
||||||
|
)
|
||||||
|
SELECT skuId, SUM(orderQty) AS orderQty
|
||||||
|
FROM ship
|
||||||
|
GROUP BY skuId`;
|
||||||
|
|
||||||
|
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
|
||||||
const rows = await runQuery(
|
const rows = await runQuery(
|
||||||
`SELECT o.sku_id AS skuId,
|
sql,
|
||||||
SUM(CAST(ISNULL(o.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
{ wmsCoId: warehouse.wmsCoId, shipFrom, shipTo },
|
||||||
FROM rpt.op_OrderNotDispatched o
|
{ requestTimeout: 300000 }
|
||||||
WHERE o.wms_co_id = @wmsCoId
|
|
||||||
AND o.status IN (${PENDING_STATUS_IN})
|
|
||||||
AND o.local_updated >= @snapDate
|
|
||||||
AND o.local_updated < DATEADD(day, 1, @snapDate)
|
|
||||||
AND ISNULL(o.sku_id, '') <> ''
|
|
||||||
GROUP BY o.sku_id`,
|
|
||||||
{ wmsCoId: warehouse.wmsCoId, snapDate },
|
|
||||||
slowOpts
|
|
||||||
);
|
);
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
(rows || []).forEach((row) => {
|
(rows || []).forEach((row) => {
|
||||||
@ -238,7 +243,7 @@ async function loadPendingOrderMap(warehouse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 组装 ────────────────────────────────────────────────────────────
|
// ── 组装 ────────────────────────────────────────────────────────────
|
||||||
function buildSkuRows({ invRows, orderMap }) {
|
function buildSkuRows({ invRows, orderMap, orderDate }) {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
(invRows || []).forEach((row) => {
|
(invRows || []).forEach((row) => {
|
||||||
const skuId = toText(row.skuId, 120);
|
const skuId = toText(row.skuId, 120);
|
||||||
@ -288,49 +293,47 @@ function buildSkuRows({ invRows, orderMap }) {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function buildSummary(rows, orderSkuCount) {
|
||||||
* 汇总只统计「待补」SKU(need / short)——
|
|
||||||
* 即需要从存储仓位补货到拣货仓位的部分。对应看板四张指标卡:
|
|
||||||
* 实时代发订单数 = 待补 SKU 待发件数合计
|
|
||||||
* 拣货库存数 = 待补 SKU 拣货位库存合计
|
|
||||||
* 存储库存数 = 待补 SKU 存储位库存合计
|
|
||||||
* 缺货数 = 存储也补不齐的 SKU 数(status=short)
|
|
||||||
*/
|
|
||||||
function buildSummary(rows) {
|
|
||||||
const summary = {
|
const summary = {
|
||||||
skuTotal: 0,
|
skuTotal: rows.length,
|
||||||
need: 0,
|
need: 0,
|
||||||
short: 0,
|
short: 0,
|
||||||
orderQtyTotal: 0,
|
enough: 0,
|
||||||
pickTotal: 0,
|
noOrder: 0,
|
||||||
storageTotal: 0,
|
pickEmpty: 0,
|
||||||
suggestQtyTotal: 0,
|
suggestQtyTotal: 0,
|
||||||
|
orderSkuCount: orderSkuCount || 0,
|
||||||
|
orderQtyTotal: 0,
|
||||||
exceedTotal: 0,
|
exceedTotal: 0,
|
||||||
shortfallTotal: 0,
|
shortfallTotal: 0,
|
||||||
|
pickTotal: 0,
|
||||||
|
storageTotal: 0,
|
||||||
};
|
};
|
||||||
|
const STATUS_SUM_KEY = { need: 'need', short: 'short', enough: 'enough', no_order: 'noOrder' };
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
summary.skuTotal += 1;
|
const key = STATUS_SUM_KEY[row.status];
|
||||||
if (row.status === STATUS.NEED) summary.need += 1;
|
if (key && summary[key] !== undefined) summary[key] += 1;
|
||||||
else if (row.status === STATUS.SHORT) summary.short += 1;
|
|
||||||
summary.orderQtyTotal += row.orderQty || 0;
|
|
||||||
summary.pickTotal += row.pickQty || 0;
|
|
||||||
summary.storageTotal += row.storageQty || 0;
|
|
||||||
summary.suggestQtyTotal += row.suggestQty || 0;
|
summary.suggestQtyTotal += row.suggestQty || 0;
|
||||||
|
summary.orderQtyTotal += row.orderQty || 0;
|
||||||
summary.exceedTotal += row.excess || 0;
|
summary.exceedTotal += row.excess || 0;
|
||||||
summary.shortfallTotal += row.shortfall || 0;
|
summary.shortfallTotal += row.shortfall || 0;
|
||||||
|
summary.pickTotal += row.pickQty || 0;
|
||||||
|
summary.storageTotal += row.storageQty || 0;
|
||||||
|
if (row.pickEmpty) summary.pickEmpty += 1;
|
||||||
});
|
});
|
||||||
summary.orderQtyTotal = round2(summary.orderQtyTotal);
|
|
||||||
summary.pickTotal = round2(summary.pickTotal);
|
|
||||||
summary.storageTotal = round2(summary.storageTotal);
|
|
||||||
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
|
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
|
||||||
|
summary.orderQtyTotal = round2(summary.orderQtyTotal);
|
||||||
summary.exceedTotal = round2(summary.exceedTotal);
|
summary.exceedTotal = round2(summary.exceedTotal);
|
||||||
summary.shortfallTotal = round2(summary.shortfallTotal);
|
summary.shortfallTotal = round2(summary.shortfallTotal);
|
||||||
|
summary.pickTotal = round2(summary.pickTotal);
|
||||||
|
summary.storageTotal = round2(summary.storageTotal);
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDataset(warehouse, { refresh = false } = {}) {
|
async function loadDataset(warehouse, { refresh = false } = {}) {
|
||||||
// 缓存键只用仓库(内部仍取「最新快照日」,不固定某天);跨天靠 TTL 失效即可。
|
const orderDate = await loadOrderDate(warehouse);
|
||||||
const cacheKey = warehouse.key;
|
// 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL
|
||||||
|
const cacheKey = `${warehouse.key}::${orderDate || 'none'}`;
|
||||||
const cached = cacheStore.get(cacheKey);
|
const cached = cacheStore.get(cacheKey);
|
||||||
const ttl = resolveCacheTtlMs();
|
const ttl = resolveCacheTtlMs();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -341,31 +344,16 @@ async function loadDataset(warehouse, { refresh = false } = {}) {
|
|||||||
const epoch = cacheEpoch;
|
const epoch = cacheEpoch;
|
||||||
const [invRows, orderMap] = await Promise.all([
|
const [invRows, orderMap] = await Promise.all([
|
||||||
loadReplicaInventory(warehouse),
|
loadReplicaInventory(warehouse),
|
||||||
loadPendingOrderMap(warehouse).catch(() => new Map()),
|
loadTodayOrderMap(warehouse, orderDate).catch(() => new Map()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 把「有打单拣货待发单、但货位库存视图里没有记录」的 SKU 也补进明细,
|
const rows = buildSkuRows({ invRows, orderMap, orderDate });
|
||||||
// 否则这批最该补货(拣货位为 0)的行会被漏掉。
|
|
||||||
const invKeys = new Set((invRows || []).map((r) => toText(r.skuId, 120)));
|
|
||||||
const orderOnly = [...orderMap.keys()]
|
|
||||||
.filter((k) => !invKeys.has(k))
|
|
||||||
.map((k) => ({ skuId: k, pickQty: 0, storageQty: 0, productName: null, supplier: null }));
|
|
||||||
const combined = (invRows || []).concat(orderOnly);
|
|
||||||
if (orderOnly.length) {
|
|
||||||
const nameMap = await loadSkuNames(orderOnly.map((r) => r.skuId));
|
|
||||||
orderOnly.forEach((r) => {
|
|
||||||
const n = nameMap.get(r.skuId);
|
|
||||||
if (n) { r.productName = n.productName; r.supplier = n.supplier; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = buildSkuRows({ invRows: combined, orderMap });
|
|
||||||
|
|
||||||
const dataset = {
|
const dataset = {
|
||||||
at: now,
|
at: now,
|
||||||
rows,
|
rows,
|
||||||
warehouse,
|
warehouse,
|
||||||
orderScope: PENDING_ORDER_SCOPE,
|
orderDate,
|
||||||
meta: {
|
meta: {
|
||||||
rowCount: rows.length,
|
rowCount: rows.length,
|
||||||
orderSkuCount: orderMap.size,
|
orderSkuCount: orderMap.size,
|
||||||
@ -382,6 +370,15 @@ function invalidateCache(warehouseKey) {
|
|||||||
else cacheStore.clear();
|
else cacheStore.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesStatus(row, status) {
|
||||||
|
const key = toText(status, 20).toLowerCase();
|
||||||
|
if (!key || key === 'all') return true;
|
||||||
|
if (key === 'action' || key === 'todo' || key === 'replenish') {
|
||||||
|
return row.status === STATUS.NEED || row.status === STATUS.SHORT;
|
||||||
|
}
|
||||||
|
return row.status === key;
|
||||||
|
}
|
||||||
|
|
||||||
function sortRows(rows) {
|
function sortRows(rows) {
|
||||||
return rows.slice().sort((a, b) => (
|
return rows.slice().sort((a, b) => (
|
||||||
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|
||||||
@ -391,19 +388,10 @@ function sortRows(rows) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function filterRows(rows, { keyword, status }) {
|
||||||
* 只看「待补」数据:需要从存储仓位补货到拣货仓位的 SKU(need / short)。
|
|
||||||
* status='short' 时仅保留存储不足(缺货)的 SKU。
|
|
||||||
*/
|
|
||||||
function filterReplenish(rows, { keyword, status }) {
|
|
||||||
const kw = toText(keyword, 120).toLowerCase();
|
const kw = toText(keyword, 120).toLowerCase();
|
||||||
const onlyShort = toText(status, 12).toLowerCase() === 'short';
|
|
||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
if (onlyShort) {
|
if (!matchesStatus(row, status)) return false;
|
||||||
if (row.status !== STATUS.SHORT) return false;
|
|
||||||
} else if (row.status !== STATUS.NEED && row.status !== STATUS.SHORT) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!kw) return true;
|
if (!kw) return true;
|
||||||
return row.skuId.toLowerCase().includes(kw)
|
return row.skuId.toLowerCase().includes(kw)
|
||||||
|| String(row.productName || '').toLowerCase().includes(kw)
|
|| String(row.productName || '').toLowerCase().includes(kw)
|
||||||
@ -414,22 +402,21 @@ function filterReplenish(rows, { keyword, status }) {
|
|||||||
async function getReplenishList(options = {}) {
|
async function getReplenishList(options = {}) {
|
||||||
const warehouse = resolveWarehouse(options.warehouse);
|
const warehouse = resolveWarehouse(options.warehouse);
|
||||||
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
|
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
|
||||||
const replenished = filterReplenish(dataset.rows, options);
|
const filtered = sortRows(filterRows(dataset.rows, options));
|
||||||
const sorted = sortRows(replenished);
|
const summary = buildSummary(dataset.rows, dataset.meta.orderSkuCount);
|
||||||
const summary = buildSummary(replenished);
|
|
||||||
const page = Math.max(1, Number.parseInt(options.page, 10) || 1);
|
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 pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50));
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: sorted.slice(start, start + pageSize),
|
rows: filtered.slice(start, start + pageSize),
|
||||||
total: sorted.length,
|
total: filtered.length,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
summary,
|
summary,
|
||||||
warehouse,
|
warehouse,
|
||||||
warehouses: Object.values(WAREHOUSES),
|
warehouses: Object.values(WAREHOUSES),
|
||||||
orderScope: PENDING_ORDER_SCOPE,
|
orderDate: dataset.orderDate,
|
||||||
cacheHit: dataset.cacheHit,
|
cacheHit: dataset.cacheHit,
|
||||||
updatedAt: new Date(dataset.at).toISOString(),
|
updatedAt: new Date(dataset.at).toISOString(),
|
||||||
cacheTtlMs: resolveCacheTtlMs(),
|
cacheTtlMs: resolveCacheTtlMs(),
|
||||||
@ -439,16 +426,15 @@ async function getReplenishList(options = {}) {
|
|||||||
async function getReplenishExportRows(options = {}) {
|
async function getReplenishExportRows(options = {}) {
|
||||||
const warehouse = resolveWarehouse(options.warehouse);
|
const warehouse = resolveWarehouse(options.warehouse);
|
||||||
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
|
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
|
||||||
const replenished = filterReplenish(dataset.rows, options);
|
|
||||||
return {
|
return {
|
||||||
warehouse,
|
warehouse,
|
||||||
orderScope: PENDING_ORDER_SCOPE,
|
orderDate: dataset.orderDate,
|
||||||
rows: sortRows(replenished),
|
rows: sortRows(filterRows(dataset.rows, options)),
|
||||||
summary: buildSummary(replenished),
|
summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启动预热:库存视图 + 待发订单 union 各自 30s~1min,不能让首个用户请求等 */
|
/** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */
|
||||||
async function warmBinReplenishCache() {
|
async function warmBinReplenishCache() {
|
||||||
try {
|
try {
|
||||||
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
|
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
|
||||||
|
|||||||
@ -158,59 +158,6 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
|
|||||||
{ keyword: '转空包', label: '信誉单' },
|
{ keyword: '转空包', label: '信誉单' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── 转仓代发(业务 2026-10-23 指定不核对异常;2026-10-24 补充不计原仓发货量)──
|
|
||||||
// 聚水潭「转仓」操作会在 `order_labels` 留一条 `转x00005` 形式的标签
|
|
||||||
// (x + 目标仓编码)。这类单**记账仓是原仓**(如崔桥工厂仓),货却由目标仓
|
|
||||||
// (x00005 = 曹县)实际发出 —— 承运账号也跟着变成曹县韵达小件。
|
|
||||||
// 结果:原仓的物流状态永远停在「未揽收 / 无物流单」,报出来全是假异常。
|
|
||||||
//
|
|
||||||
// 实测近 30 天(12 仓):`转x00005` 438 单,报未揽收类 46 单(10.5%);
|
|
||||||
// 已签收 371 / 无物流单 31 / 运输中 20 / 未揽收 15。
|
|
||||||
// 快递分布:曹县韵达小件 133、邮政快递包裹POSTB 124、睢宁中通 41 ……
|
|
||||||
// 目前只出现过 `转x00005` 一种编码,但标签是机器生成的(后续可能出现 x00006),
|
|
||||||
// 所以用正则匹配「转x<3位以上数字>」,不写死单个编码。
|
|
||||||
// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**。
|
|
||||||
// 🔥 口径:**整单不计** —— 货不是从原仓走的,既不算原仓异常,也不进原仓发货分母
|
|
||||||
// (记进分母会把分母做大、异常率被稀释)。这与「现场拦截」相反,后者货是本仓发的、留分母。
|
|
||||||
const TRANSFER_WAREHOUSE_LABEL_PATTERN = /转\s*[xX]\s*\d{3,}/;
|
|
||||||
|
|
||||||
// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)────────
|
|
||||||
// 业务原话:「把现场拦截的给去掉,也就是发的货被售后签收了」,判据是
|
|
||||||
// **售后单有「签收单号」且「实退数量」不为 0**。
|
|
||||||
//
|
|
||||||
// 这是什么形态:客户不要了 / 派送时被拦下,快递当场把货拉回(轨迹常写
|
|
||||||
// 「异常签收(拦截退回)」),货退回后由售后仓签收入库。物流环节其实已经结束,
|
|
||||||
// 不属于「卡在网点发不出去」,报成转运异常是假警报。
|
|
||||||
//
|
|
||||||
// 落到库里的字段(聚水潭售后单两张表,店铺互斥,必须都读):
|
|
||||||
// · jst_returnAndRefundInfo + jst_returnAndRefundItem (绝大多数店铺)
|
|
||||||
// · jst_qmcust_refundlist + jst_qmcust_refundlist_item (企业微信/千牛客服单)
|
|
||||||
// 签收单号 = 主表 `l_id`(退回那一票的运单号,实测常与原发货单号相同)
|
|
||||||
// 实退数量 = 明细表 `r_qty`(申请数量是 `qty`,**只能看 r_qty**:
|
|
||||||
// 拒收未签收的 r_qty=0、仅退款/补发的 r_qty 恒为 0)
|
|
||||||
//
|
|
||||||
// 实测(2026-10-23 批次,36 个异常包裹)命中 3 单,全是「拒收退货 + r_qty=1 +
|
|
||||||
// l_id 非空」,和业务描述完全一致;同期 9859198/9871523 虽然 l_id 非空但
|
|
||||||
// r_qty=0(货还在路上),正确地没被剔。
|
|
||||||
const INTERCEPT_AFTERSALE_SOURCES = Object.freeze([
|
|
||||||
{
|
|
||||||
key: 'returnAndRefund',
|
|
||||||
header: 'dbo.jst_returnAndRefundInfo',
|
|
||||||
item: 'dbo.jst_returnAndRefundItem',
|
|
||||||
// 主表 as_id=bigint、明细 as_id=int → 显式收窄,避免隐式转换报错
|
|
||||||
headerAsId: 'TRY_CONVERT(int, h.as_id)',
|
|
||||||
label: '售后单',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'qmcustRefund',
|
|
||||||
header: 'dbo.jst_qmcust_refundlist',
|
|
||||||
item: 'dbo.jst_qmcust_refundlist_item',
|
|
||||||
// 两张表 as_id 都是 nvarchar,直接等值(别传数字 IN,会把列转 int 撞脏数据)
|
|
||||||
headerAsId: 'h.as_id',
|
|
||||||
label: '客服售后单',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
|
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
|
||||||
const SORTED_KEYWORDS = CENTERS
|
const SORTED_KEYWORDS = CENTERS
|
||||||
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
|
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
|
||||||
@ -369,19 +316,6 @@ function matchNonRealOrder(orderLabels) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 转仓代发 → 本仓不用核对物流异常。
|
|
||||||
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
|
|
||||||
* @returns {string|null} 命中的标签原文(如 `转x00005`),便于页脚直接展示;不命中返回 null
|
|
||||||
*/
|
|
||||||
function matchTransferWarehouse(orderLabels) {
|
|
||||||
const labels = String(orderLabels || '');
|
|
||||||
if (!labels) return null;
|
|
||||||
const hit = labels.match(TRANSFER_WAREHOUSE_LABEL_PATTERN);
|
|
||||||
if (!hit) return null;
|
|
||||||
return hit[0].replace(/\s+/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析「核验批次」。
|
* 解析「核验批次」。
|
||||||
*
|
*
|
||||||
@ -436,7 +370,6 @@ module.exports = {
|
|||||||
EXCLUDED_ORDER_STATUS_KEYWORDS,
|
EXCLUDED_ORDER_STATUS_KEYWORDS,
|
||||||
EXCLUDED_ORDER_LABEL_KEYWORDS,
|
EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||||||
EXCLUDED_ORDER_TYPE_LABELS,
|
EXCLUDED_ORDER_TYPE_LABELS,
|
||||||
INTERCEPT_AFTERSALE_SOURCES,
|
|
||||||
EXCEPTION_TYPES,
|
EXCEPTION_TYPES,
|
||||||
TYPE_MAP,
|
TYPE_MAP,
|
||||||
TRANSIT_INFO_KEYWORDS,
|
TRANSIT_INFO_KEYWORDS,
|
||||||
@ -446,8 +379,6 @@ module.exports = {
|
|||||||
matchExcludedCarrier,
|
matchExcludedCarrier,
|
||||||
matchVoidedOrder,
|
matchVoidedOrder,
|
||||||
matchNonRealOrder,
|
matchNonRealOrder,
|
||||||
matchTransferWarehouse,
|
|
||||||
TRANSFER_WAREHOUSE_LABEL_PATTERN,
|
|
||||||
resolveBatch,
|
resolveBatch,
|
||||||
beijingNow,
|
beijingNow,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
|||||||
@ -148,72 +148,6 @@ async function loadShippedPackages(batch) {
|
|||||||
return rows || [];
|
return rows || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 售后拦截判定:一次 IN 多少单 + 查询超时。
|
|
||||||
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
|
|
||||||
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
|
|
||||||
// · 只查「本来就会报异常」的 oId(正常单不查,量级从 9800 降到几十);
|
|
||||||
// · 两套店铺表**并发**查,总耗时取较慢的那条(~8.5s),而不是相加的 13s;
|
|
||||||
// · 结果进 5 分钟结果缓存,页面自动刷新时基本命中缓存。
|
|
||||||
const INTERCEPT_QUERY_BATCH = 500;
|
|
||||||
const INTERCEPT_QUERY_TIMEOUT_MS = 240000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 找出「现场拦截、货已被售后仓签收」的订单(业务 2026-10-23 口径)。
|
|
||||||
*
|
|
||||||
* 判据 = 售后单**签收单号 `l_id` 非空** 且 明细**实退数量 `r_qty` 合计 > 0**。
|
|
||||||
* 两张店铺售后表互斥,必须都查(见 registry.INTERCEPT_AFTERSALE_SOURCES)。
|
|
||||||
*
|
|
||||||
* @param {Array} rows 状态表原始行
|
|
||||||
* @returns {Promise<Set<string>>} 内部单号字符串集合
|
|
||||||
*/
|
|
||||||
async function loadInterceptedOrders(rows) {
|
|
||||||
const ids = [];
|
|
||||||
const seen = new Set();
|
|
||||||
for (const row of rows) {
|
|
||||||
// 只关心本来会被判成异常的单:正常单(已签收/运输中)剔不剔都一样
|
|
||||||
if (!classify(row)) continue;
|
|
||||||
const id = Number(row.inner_order_id);
|
|
||||||
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
const intercepted = new Set();
|
|
||||||
if (!ids.length) return intercepted;
|
|
||||||
|
|
||||||
const tasks = [];
|
|
||||||
for (let i = 0; i < ids.length; i += INTERCEPT_QUERY_BATCH) {
|
|
||||||
const oIds = ids.slice(i, i + INTERCEPT_QUERY_BATCH).join(',');
|
|
||||||
for (const source of registry.INTERCEPT_AFTERSALE_SOURCES) {
|
|
||||||
tasks.push((async () => {
|
|
||||||
const hits = await cloudDatabase.query(
|
|
||||||
`SELECT h.o_id
|
|
||||||
FROM ${source.header} h WITH (NOLOCK)
|
|
||||||
JOIN ${source.item} it WITH (NOLOCK) ON it.as_id = ${source.headerAsId}
|
|
||||||
WHERE h.o_id IN (${oIds})
|
|
||||||
AND ISNULL(h.l_id, '') <> ''
|
|
||||||
GROUP BY h.o_id
|
|
||||||
HAVING SUM(ISNULL(it.r_qty, 0)) > 0`,
|
|
||||||
{},
|
|
||||||
{ timeoutMs: INTERCEPT_QUERY_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
return hits || [];
|
|
||||||
})().catch((err) => {
|
|
||||||
// 售后表跟不上不该让整页挂掉:退化成「不剔除」,页脚会显示 0 单,口径说明仍在
|
|
||||||
console.warn(`[WLE] 售后拦截判定失败(${source.key}):${err.message}`);
|
|
||||||
return [];
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(tasks);
|
|
||||||
for (const list of results) {
|
|
||||||
for (const r of list) {
|
|
||||||
if (r && r.o_id !== null && r.o_id !== undefined) intercepted.add(String(r.o_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return intercepted;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptySummary() {
|
function emptySummary() {
|
||||||
return {
|
return {
|
||||||
shippedOrders: 0,
|
shippedOrders: 0,
|
||||||
@ -249,25 +183,6 @@ function exclusionMeta() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 缓存里存的是**全量明细**(不截断),limit 只在出口生效。
|
|
||||||
*
|
|
||||||
* 🔥 2026-10-23 修:缓存键以前是 `${核验日}|${limit}`,页面传 1000、导出传 5000
|
|
||||||
* → 两份缓存互不命中,点一次导出就要把整轮重算一遍(生产实测 317s,而实际
|
|
||||||
* 异常只有 33 行,limit 根本不影响扫库成本)。现在键只按核验日,出口再 slice。
|
|
||||||
*/
|
|
||||||
function finalizeDetail(data, limit, cached, cacheAgeSec) {
|
|
||||||
const all = Array.isArray(data.orders) ? data.orders : [];
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
orders: all.slice(0, limit),
|
|
||||||
detailLimit: limit,
|
|
||||||
truncated: all.length > limit,
|
|
||||||
cached,
|
|
||||||
cacheAgeSec,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLogisticsExceptions(options = {}) {
|
async function getLogisticsExceptions(options = {}) {
|
||||||
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
|
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
|
||||||
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
|
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
|
||||||
@ -305,12 +220,6 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
nonRealPackages: 0,
|
nonRealPackages: 0,
|
||||||
nonRealOrders: 0,
|
nonRealOrders: 0,
|
||||||
nonRealReasons: {},
|
nonRealReasons: {},
|
||||||
interceptPackages: 0,
|
|
||||||
interceptOrders: 0,
|
|
||||||
interceptReasons: {},
|
|
||||||
transferPackages: 0,
|
|
||||||
transferOrders: 0,
|
|
||||||
transferReasons: {},
|
|
||||||
},
|
},
|
||||||
orders: [],
|
orders: [],
|
||||||
detailLimit: limit,
|
detailLimit: limit,
|
||||||
@ -322,24 +231,18 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 键只到核验日 —— 页面(limit=1000)与导出(limit=5000)共用同一份全量结果
|
const cacheKey = `${batch.verifyDate}|${limit}`;
|
||||||
const cacheKey = batch.verifyDate;
|
|
||||||
const cachedEntry = resultCache.get(cacheKey);
|
const cachedEntry = resultCache.get(cacheKey);
|
||||||
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
|
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
|
||||||
return finalizeDetail(
|
return {
|
||||||
cachedEntry.data,
|
...cachedEntry.data,
|
||||||
limit,
|
cached: true,
|
||||||
true,
|
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
|
||||||
Math.round((Date.now() - cachedEntry.at) / 1000)
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await loadShippedPackages(batch);
|
const rows = await loadShippedPackages(batch);
|
||||||
|
|
||||||
// ── 现场拦截(售后已签收)—— 只对会报异常的单去售后表核对 ─────────────
|
|
||||||
// 必须放在主循环之前:售后表要批量 IN 查,逐单查会把页面拖到几分钟。
|
|
||||||
const interceptedOrders = await loadInterceptedOrders(rows);
|
|
||||||
|
|
||||||
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||||||
const centerStat = new Map();
|
const centerStat = new Map();
|
||||||
for (const center of registry.CENTERS) {
|
for (const center of registry.CENTERS) {
|
||||||
@ -363,8 +266,6 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
|
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
|
||||||
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
|
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
|
||||||
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
|
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
|
||||||
const skippedByIntercept = { packages: 0, orders: new Set(), reasons: {} };
|
|
||||||
const skippedByTransfer = { packages: 0, orders: new Set(), reasons: {} };
|
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const queryTime = formatDateTime(row.query_time);
|
const queryTime = formatDateTime(row.query_time);
|
||||||
@ -408,18 +309,6 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓实际发出。
|
|
||||||
// 业务 2026-10-24 定版:**原仓的发货量也不该算** —— 货不是从这个仓走的,
|
|
||||||
// 记在原仓只会把发货分母做大、异常率稀释。所以按「整单不计」处理
|
|
||||||
// (既不算异常、也不进发货分母),与承运商/刷单那几类同级,不是「只摘异常」。
|
|
||||||
const transferReason = registry.matchTransferWarehouse(row.order_labels);
|
|
||||||
if (transferReason) {
|
|
||||||
skippedByTransfer.packages += 1;
|
|
||||||
skippedByTransfer.orders.add(oId);
|
|
||||||
skippedByTransfer.reasons[transferReason] = (skippedByTransfer.reasons[transferReason] || 0) + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stat = centerStat.get(center.key);
|
const stat = centerStat.get(center.key);
|
||||||
stat.shippedOrders.add(oId);
|
stat.shippedOrders.add(oId);
|
||||||
stat.packages += 1;
|
stat.packages += 1;
|
||||||
@ -429,23 +318,11 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
const type = classify(row);
|
const type = classify(row);
|
||||||
if (!type) continue;
|
if (!type) continue;
|
||||||
|
|
||||||
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
|
||||||
|
|
||||||
// ⑤ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
|
|
||||||
// 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。
|
|
||||||
// ⚠️ 与上面几类不同:这里**保留发货分母**(货确实从这个仓发出去了),
|
|
||||||
// 只从异常清单里摘掉,异常率才不会被错误地放大。
|
|
||||||
if (interceptedOrders.has(oId)) {
|
|
||||||
skippedByIntercept.packages += 1;
|
|
||||||
skippedByIntercept.orders.add(oId);
|
|
||||||
skippedByIntercept.reasons[typeMeta.label] = (skippedByIntercept.reasons[typeMeta.label] || 0) + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendAt = formatDateTime(row.send_time);
|
const sendAt = formatDateTime(row.send_time);
|
||||||
const logisticAt = formatDateTime(row.logistic_time);
|
const logisticAt = formatDateTime(row.logistic_time);
|
||||||
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
|
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
|
||||||
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
|
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
|
||||||
|
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||||||
|
|
||||||
stat.exceptionOrders.add(oId);
|
stat.exceptionOrders.add(oId);
|
||||||
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
||||||
@ -543,19 +420,10 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
nonRealPackages: skippedByType.packages,
|
nonRealPackages: skippedByType.packages,
|
||||||
nonRealOrders: skippedByType.orders.size,
|
nonRealOrders: skippedByType.orders.size,
|
||||||
nonRealReasons: skippedByType.reasons,
|
nonRealReasons: skippedByType.reasons,
|
||||||
// 现场拦截(售后已签收)—— 只从异常里摘掉,仍计入发货分母
|
|
||||||
interceptPackages: skippedByIntercept.packages,
|
|
||||||
interceptOrders: skippedByIntercept.orders.size,
|
|
||||||
interceptReasons: skippedByIntercept.reasons,
|
|
||||||
// 转仓代发(转x00005)—— 整单不计:既不算异常,也不进原仓发货分母
|
|
||||||
transferPackages: skippedByTransfer.packages,
|
|
||||||
transferOrders: skippedByTransfer.orders.size,
|
|
||||||
transferReasons: skippedByTransfer.reasons,
|
|
||||||
},
|
},
|
||||||
// 缓存里存全量,limit 由 finalizeDetail 在出口截断
|
orders: exceptions.slice(0, limit),
|
||||||
orders: exceptions,
|
|
||||||
detailLimit: limit,
|
detailLimit: limit,
|
||||||
truncated: false,
|
truncated: exceptions.length > limit,
|
||||||
freshness: {
|
freshness: {
|
||||||
snapshotAt,
|
snapshotAt,
|
||||||
ageMinutes,
|
ageMinutes,
|
||||||
@ -578,7 +446,7 @@ async function getLogisticsExceptions(options = {}) {
|
|||||||
}
|
}
|
||||||
if (oldestKey) resultCache.delete(oldestKey);
|
if (oldestKey) resultCache.delete(oldestKey);
|
||||||
}
|
}
|
||||||
return finalizeDetail(payload, limit, false, 0);
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,373 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「仓储中心 24 小时发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
|
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
|
||||||
* 业务口径(2026-10-27):
|
|
||||||
* 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」
|
|
||||||
* ⟹ **一天两推**,各自绑一个核验轮次:
|
|
||||||
* NOON 13:00 核验(窗口上界 07:00 前审核)→ 默认 13:05 推
|
|
||||||
* AFTERNOON 18:00 核验(窗口上界 16:00 前审核)→ 默认 18:05 推
|
|
||||||
* ⚠️ 默认给到「核验时刻 + 5 分钟」而不是整点:状态镜像 `query_time` 要略晚于核验时刻,
|
|
||||||
* 否则 `freshness.coversDeadline=false`(数据没覆盖本轮核验点),推出去的超时数必然偏大。
|
|
||||||
* 业务要改时刻在页面上改,不必重启后端。
|
|
||||||
*
|
|
||||||
* 通道 = 钉钉群自定义机器人 webhook(与「物流转运异常」同一套,只发 text、不发附件)。
|
|
||||||
* 配置分两层存(与 yydNotificationCenterSettings 同一套做法):
|
|
||||||
* · 普通字段 → 本地库 `system_config`,key = warehouse_ship_timeliness_notify_config
|
|
||||||
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
|
|
||||||
*
|
|
||||||
* 状态另存一份(key = warehouse_ship_timeliness_notify_state),按 `<日期>|<轮次键>` 去重 ——
|
|
||||||
* 服务重启、每分钟 tick 重复触发都不会重复轰炸;页面「立即推送」才强制重发。
|
|
||||||
*/
|
|
||||||
|
|
||||||
const database = require('../config/database');
|
|
||||||
const {
|
|
||||||
getResilientSecret,
|
|
||||||
saveResilientSecret,
|
|
||||||
} = require('./resilientSecretStore');
|
|
||||||
|
|
||||||
const CONFIG_KEY = 'warehouse_ship_timeliness_notify_config';
|
|
||||||
const SECRET_KEY = 'warehouse_ship_timeliness_notify_webhook_secret';
|
|
||||||
const STATE_KEY = 'warehouse_ship_timeliness_notify_state';
|
|
||||||
|
|
||||||
// 两个轮次的默认推送时刻(核验 13:00 / 18:00 + 5 分钟,见文件头说明)
|
|
||||||
const DEFAULT_SEND_TIME_NOON = '13:05';
|
|
||||||
const DEFAULT_SEND_TIME_AFTERNOON = '18:05';
|
|
||||||
|
|
||||||
// 明细条数:text 上限 20000 字节,30 条中文明细约 9~12KB,留足余量
|
|
||||||
const DEFAULT_MAX_DETAIL_ROWS = 30;
|
|
||||||
const MAX_DETAIL_ROWS_LIMIT = 50;
|
|
||||||
|
|
||||||
// 页面地址(消息末尾「查看全部」用)。生产入口带 8443,别写成裸域名
|
|
||||||
const DEFAULT_PAGE_URL = process.env.WST_PAGE_URL
|
|
||||||
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness';
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 30 * 1000;
|
|
||||||
|
|
||||||
// 状态里最多保留多少条「日期|轮次」记录(一天两条,30 天足够,避免 system_config 无限长)
|
|
||||||
const MAX_STATE_ENTRIES = 60;
|
|
||||||
|
|
||||||
let cachedConfig = null;
|
|
||||||
let cachedAt = 0;
|
|
||||||
|
|
||||||
function normalizeText(value, maxLength = 512) {
|
|
||||||
return String(value ?? '').trim().slice(0, maxLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeJsonParse(value, fallback = {}) {
|
|
||||||
if (value && typeof value === 'object') return value;
|
|
||||||
try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskSecret(value) {
|
|
||||||
const text = normalizeText(value);
|
|
||||||
if (!text) return '';
|
|
||||||
if (text.length <= 8) return `${text.slice(0, 2)}******`;
|
|
||||||
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HH:mm(00:00 ~ 23:59);非法输入退回默认值,不抛错(避免把定时任务配挂) */
|
|
||||||
function normalizeSendTime(value, fallback = DEFAULT_SEND_TIME_NOON) {
|
|
||||||
const text = normalizeText(value, 5);
|
|
||||||
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */
|
|
||||||
function normalizeMaxDetailRows(value) {
|
|
||||||
const num = Number(value);
|
|
||||||
if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS;
|
|
||||||
return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePageUrl(value) {
|
|
||||||
const text = normalizeText(value, 1024);
|
|
||||||
if (!text) return DEFAULT_PAGE_URL;
|
|
||||||
try {
|
|
||||||
const url = new URL(text);
|
|
||||||
if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL;
|
|
||||||
return url.toString();
|
|
||||||
} catch (_error) {
|
|
||||||
return DEFAULT_PAGE_URL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBoolean(value, fallback = false) {
|
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
|
||||||
if (typeof value === 'boolean') return value;
|
|
||||||
const text = String(value).trim().toLowerCase();
|
|
||||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
|
||||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertConfigRow(value) {
|
|
||||||
const rows = await database.local.query(
|
|
||||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
|
||||||
[CONFIG_KEY]
|
|
||||||
);
|
|
||||||
const text = JSON.stringify(value);
|
|
||||||
if (rows[0]) {
|
|
||||||
await database.local.query(
|
|
||||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
|
||||||
[text, 'json', CONFIG_KEY]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await database.local.insert('system_config', {
|
|
||||||
config_key: CONFIG_KEY,
|
|
||||||
config_value: text,
|
|
||||||
config_type: 'json',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readConfigRow() {
|
|
||||||
try {
|
|
||||||
const rows = await database.local.query(
|
|
||||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
|
||||||
[CONFIG_KEY]
|
|
||||||
);
|
|
||||||
return safeJsonParse(rows[0]?.configValue, {});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WstNotify] 读取推送配置失败:', error.message);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
|
|
||||||
async function getShipTimelinessNotifyRuntimeConfig({ forceRefresh = false } = {}) {
|
|
||||||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig };
|
|
||||||
|
|
||||||
const stored = await readConfigRow();
|
|
||||||
const secret = await getResilientSecret(SECRET_KEY, {
|
|
||||||
legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)],
|
|
||||||
fallbackValues: [normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
|
|
||||||
});
|
|
||||||
|
|
||||||
cachedConfig = {
|
|
||||||
enabled: normalizeBoolean(stored.enabled, false),
|
|
||||||
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|
|
||||||
|| normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK, 1024),
|
|
||||||
webhookSecret: secret.value,
|
|
||||||
webhookSecretSource: secret.source,
|
|
||||||
// 两轮各自的推送时刻
|
|
||||||
noonSendTime: normalizeSendTime(
|
|
||||||
stored.noonSendTime || stored.noon_send_time,
|
|
||||||
DEFAULT_SEND_TIME_NOON
|
|
||||||
),
|
|
||||||
afternoonSendTime: normalizeSendTime(
|
|
||||||
stored.afternoonSendTime || stored.afternoon_send_time,
|
|
||||||
DEFAULT_SEND_TIME_AFTERNOON
|
|
||||||
),
|
|
||||||
maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows),
|
|
||||||
atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false),
|
|
||||||
pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url),
|
|
||||||
source: Object.keys(stored).length > 0 ? 'database' : 'default',
|
|
||||||
};
|
|
||||||
cachedAt = Date.now();
|
|
||||||
return { ...cachedConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/
|
|
||||||
function missingFields(config = {}) {
|
|
||||||
const missing = [];
|
|
||||||
if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址');
|
|
||||||
return missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isShipTimelinessNotifyReady(config = {}) {
|
|
||||||
return missingFields(config).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAdminConfig(config = {}) {
|
|
||||||
return {
|
|
||||||
enabled: config.enabled === true,
|
|
||||||
webhookUrl: config.webhookUrl || '',
|
|
||||||
webhookSecretConfigured: Boolean(config.webhookSecret),
|
|
||||||
webhookSecretPreview: maskSecret(config.webhookSecret),
|
|
||||||
webhookSecretSource: config.webhookSecretSource || 'unconfigured',
|
|
||||||
noonSendTime: normalizeSendTime(config.noonSendTime, DEFAULT_SEND_TIME_NOON),
|
|
||||||
afternoonSendTime: normalizeSendTime(config.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON),
|
|
||||||
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
|
|
||||||
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
|
|
||||||
atAll: config.atAll === true,
|
|
||||||
pageUrl: normalizePageUrl(config.pageUrl),
|
|
||||||
ready: isShipTimelinessNotifyReady(config),
|
|
||||||
missingFields: missingFields(config),
|
|
||||||
source: config.source || 'default',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getShipTimelinessNotifyAdminConfig() {
|
|
||||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
|
||||||
return toAdminConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
|
|
||||||
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
|
|
||||||
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
|
|
||||||
*/
|
|
||||||
async function saveShipTimelinessNotifyConfig(input = {}) {
|
|
||||||
const current = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
|
||||||
const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512);
|
|
||||||
const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true;
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
enabled: normalizeBoolean(input.enabled, current.enabled),
|
|
||||||
webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl,
|
|
||||||
webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret),
|
|
||||||
noonSendTime: input.noonSendTime === undefined && input.noon_send_time === undefined
|
|
||||||
? normalizeSendTime(current.noonSendTime, DEFAULT_SEND_TIME_NOON)
|
|
||||||
: normalizeSendTime(input.noonSendTime ?? input.noon_send_time, DEFAULT_SEND_TIME_NOON),
|
|
||||||
afternoonSendTime: input.afternoonSendTime === undefined && input.afternoon_send_time === undefined
|
|
||||||
? normalizeSendTime(current.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON)
|
|
||||||
: normalizeSendTime(input.afternoonSendTime ?? input.afternoon_send_time, DEFAULT_SEND_TIME_AFTERNOON),
|
|
||||||
maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined
|
|
||||||
? normalizeMaxDetailRows(current.maxDetailRows)
|
|
||||||
: normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows),
|
|
||||||
atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true),
|
|
||||||
pageUrl: input.pageUrl === undefined && input.page_url === undefined
|
|
||||||
? normalizePageUrl(current.pageUrl)
|
|
||||||
: normalizePageUrl(input.pageUrl ?? input.page_url),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.enabled) {
|
|
||||||
const missing = missingFields(config);
|
|
||||||
if (missing.length) {
|
|
||||||
throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.webhookUrl) {
|
|
||||||
// 这里只做「像不像」的浅校验;真正的连通性由「测试通道」按钮验证
|
|
||||||
const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender');
|
|
||||||
try {
|
|
||||||
config.webhookUrl = normalizeWebhookUrl(config.webhookUrl);
|
|
||||||
} catch (error) {
|
|
||||||
throw Object.assign(new Error(error.message), { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret);
|
|
||||||
await upsertConfigRow({
|
|
||||||
enabled: config.enabled,
|
|
||||||
webhookUrl: config.webhookUrl,
|
|
||||||
noonSendTime: config.noonSendTime,
|
|
||||||
afternoonSendTime: config.afternoonSendTime,
|
|
||||||
maxDetailRows: config.maxDetailRows,
|
|
||||||
atAll: config.atAll,
|
|
||||||
pageUrl: config.pageUrl,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
cachedConfig = {
|
|
||||||
...config,
|
|
||||||
webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')),
|
|
||||||
source: 'database',
|
|
||||||
};
|
|
||||||
cachedAt = Date.now();
|
|
||||||
return toAdminConfig(cachedConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推送状态。
|
|
||||||
* 结构(version 1):
|
|
||||||
* {
|
|
||||||
* version: 1,
|
|
||||||
* sent: { '2026-10-27|NOON': { sentAt, rowCount, listedCount, bytes, deadline } , ... },
|
|
||||||
* lastSentAt, lastStatus, lastError, lastFailedAt, lastRoundLabel
|
|
||||||
* }
|
|
||||||
* sent 只保留最近 MAX_STATE_ENTRIES 条(一天两轮,30 天足够)。
|
|
||||||
*/
|
|
||||||
async function loadShipTimelinessNotifyState() {
|
|
||||||
try {
|
|
||||||
const rows = await database.local.query(
|
|
||||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
|
||||||
[STATE_KEY]
|
|
||||||
);
|
|
||||||
const state = safeJsonParse(rows[0]?.configValue, {});
|
|
||||||
if (!state || typeof state !== 'object') return {};
|
|
||||||
return { ...state, sent: (state.sent && typeof state.sent === 'object') ? state.sent : {} };
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WstNotify] 读取推送状态失败:', error.message);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 去重键:`<北京日期>|<轮次键>` —— 一天两轮各自独立 */
|
|
||||||
function buildPushSlot(dateKey, roundKey) {
|
|
||||||
const date = normalizeText(dateKey, 10);
|
|
||||||
const round = normalizeText(roundKey, 20).toUpperCase();
|
|
||||||
if (!date || !round) return '';
|
|
||||||
return `${date}|${round}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveShipTimelinessNotifyState(next = {}) {
|
|
||||||
const value = JSON.stringify(next && typeof next === 'object' ? next : {});
|
|
||||||
try {
|
|
||||||
const rows = await database.local.query(
|
|
||||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
|
||||||
[STATE_KEY]
|
|
||||||
);
|
|
||||||
if (rows[0]) {
|
|
||||||
await database.local.query(
|
|
||||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
|
||||||
[value, 'json', STATE_KEY]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await database.local.insert('system_config', {
|
|
||||||
config_key: STATE_KEY,
|
|
||||||
config_value: value,
|
|
||||||
config_type: 'json',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WstNotify] 保存推送状态失败:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把 sent 裁剪到 MAX_STATE_ENTRIES 条,按 slot 日期倒序保留(新日期优先) */
|
|
||||||
function trimSentMap(sent = {}) {
|
|
||||||
const entries = Object.entries(sent || {});
|
|
||||||
if (entries.length <= MAX_STATE_ENTRIES) return sent;
|
|
||||||
entries.sort((a, b) => {
|
|
||||||
const da = String(a[1]?.sentAt || '');
|
|
||||||
const db = String(b[1]?.sentAt || '');
|
|
||||||
return db.localeCompare(da);
|
|
||||||
});
|
|
||||||
return Object.fromEntries(entries.slice(0, MAX_STATE_ENTRIES));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearShipTimelinessNotifyConfigCache() {
|
|
||||||
cachedConfig = null;
|
|
||||||
cachedAt = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
CONFIG_KEY,
|
|
||||||
DEFAULT_MAX_DETAIL_ROWS,
|
|
||||||
DEFAULT_PAGE_URL,
|
|
||||||
DEFAULT_SEND_TIME_AFTERNOON,
|
|
||||||
DEFAULT_SEND_TIME_NOON,
|
|
||||||
MAX_DETAIL_ROWS_LIMIT,
|
|
||||||
MAX_STATE_ENTRIES,
|
|
||||||
SECRET_KEY,
|
|
||||||
STATE_KEY,
|
|
||||||
buildPushSlot,
|
|
||||||
clearShipTimelinessNotifyConfigCache,
|
|
||||||
getShipTimelinessNotifyAdminConfig,
|
|
||||||
getShipTimelinessNotifyRuntimeConfig,
|
|
||||||
isShipTimelinessNotifyReady,
|
|
||||||
loadShipTimelinessNotifyState,
|
|
||||||
maskSecret,
|
|
||||||
missingFields,
|
|
||||||
normalizeMaxDetailRows,
|
|
||||||
normalizePageUrl,
|
|
||||||
normalizeSendTime,
|
|
||||||
saveShipTimelinessNotifyConfig,
|
|
||||||
saveShipTimelinessNotifyState,
|
|
||||||
toAdminConfig,
|
|
||||||
trimSentMap,
|
|
||||||
};
|
|
||||||
@ -307,30 +307,8 @@ const CREATED_BACKFILL_BATCH = 200;
|
|||||||
// 单子早随主单发走了,状态表还停在「发货中」。
|
// 单子早随主单发走了,状态表还停在「发货中」。
|
||||||
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
||||||
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
||||||
// 🔥 2026-10-22 补 `Question` —— **但不能塞进这个数组**:
|
|
||||||
// 这个数组在「状态分类之前」就判定,会把状态表「异常」的单(订单表常是 Question)
|
|
||||||
// 从「异常待处理」清单里一起吞掉(实测 heldCount 472 → 10,少掉 462 单)。
|
|
||||||
// ⟹ Question 单独在 `state === '发货中'` 之后判,见 VOID_QUESTION_STATUS。
|
|
||||||
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
||||||
|
|
||||||
// 订单表 status=Question(聚水潭后台显示「已作废/问题单」)—— 业务 2026-10-22 确认:
|
|
||||||
// so_id 3655421011854634(9823980) / 3658479011191285(9880810) 在后台都已作废,
|
|
||||||
// 而状态表 order_status_name 仍停在「发货中」+ send_time 空、售后「暂无售后」
|
|
||||||
// ⟹ 上游状态没同步,只信状态表判不出来。仅在「发货中」分支生效,不影响异常清单。
|
|
||||||
const VOID_QUESTION_STATUS = 'Question';
|
|
||||||
|
|
||||||
// 订单表 `labels` 里出现这些关键词,等价于「客户发货前退款/取消」= 作废,不考核仓库。
|
|
||||||
// 实测 9823980 的 labels = 「发货前售后,大单待确认,店铺-京东,晚发赔,禁止拆分」;
|
|
||||||
// 已取消单(order_status_name='已取消')几乎全都带这个标签,可作交叉验证。
|
|
||||||
// ⚠️ `labels` 是长文本列,回订单表补它会让 backfill 变慢(见 service 里的投影说明),
|
|
||||||
// 所以只投影一个 `CASE WHEN ... THEN 1 ELSE 0 END` 布尔列,不把原文传回来。
|
|
||||||
const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
|
||||||
|
|
||||||
// 状态表 `aftersale_status_name` 的「已作废」—— 售后维度最权威的作废标记。
|
|
||||||
// 目前带这个值的单在订单表基本都是 Sent(已被其它判据剔除),这里是防御性兜底:
|
|
||||||
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
|
||||||
const AFTERSALE_VOID_STATUS = '已作废';
|
|
||||||
|
|
||||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||||
@ -576,9 +554,6 @@ module.exports = {
|
|||||||
CREATED_SOURCE_TABLES,
|
CREATED_SOURCE_TABLES,
|
||||||
CREATED_BACKFILL_BATCH,
|
CREATED_BACKFILL_BATCH,
|
||||||
VOID_ORDER_STATUS,
|
VOID_ORDER_STATUS,
|
||||||
VOID_QUESTION_STATUS,
|
|
||||||
VOID_ORDER_LABEL_KEYWORDS,
|
|
||||||
AFTERSALE_VOID_STATUS,
|
|
||||||
SHIP_TIMELINESS_HOURS,
|
SHIP_TIMELINESS_HOURS,
|
||||||
toNumber,
|
toNumber,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,11 +26,9 @@
|
|||||||
* (它们早就该发了,不该等今天的分段窗口)。
|
* (它们早就该发了,不该等今天的分段窗口)。
|
||||||
* ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。
|
* ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。
|
||||||
* 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。
|
* 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。
|
||||||
* ④ **自提 / 中铁 / 壹米滴答 / 武进安能 四个快递不考核**(业务 2026-10-20 补充,
|
* ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充):
|
||||||
* 武进安能 2026-10-27 追加):
|
|
||||||
* 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」),
|
* 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」),
|
||||||
* 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。
|
* 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。
|
||||||
* ⚠️ 武进安能只剔这一个网点,别泛化成「安能」(常州安能新等仍考核)。
|
|
||||||
*
|
*
|
||||||
* 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。
|
* 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。
|
||||||
* 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)——
|
* 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)——
|
||||||
@ -131,17 +129,9 @@ const {
|
|||||||
CREATED_SOURCE_TABLES,
|
CREATED_SOURCE_TABLES,
|
||||||
CREATED_BACKFILL_BATCH,
|
CREATED_BACKFILL_BATCH,
|
||||||
VOID_ORDER_STATUS,
|
VOID_ORDER_STATUS,
|
||||||
VOID_QUESTION_STATUS,
|
|
||||||
VOID_ORDER_LABEL_KEYWORDS,
|
|
||||||
AFTERSALE_VOID_STATUS,
|
|
||||||
resolveCenter,
|
resolveCenter,
|
||||||
} = registry;
|
} = registry;
|
||||||
|
|
||||||
// `labels` 里命中任一关键词即视为作废(SQL 侧用 LIKE 拼,避免把长文本传回 Node)
|
|
||||||
const VOID_LABEL_SQL = VOID_ORDER_LABEL_KEYWORDS
|
|
||||||
.map((k) => `labels LIKE N'%${k}%'`)
|
|
||||||
.join(' OR ');
|
|
||||||
|
|
||||||
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
|
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
|
||||||
const DEFAULT_DETAIL_LIMIT = 500;
|
const DEFAULT_DETAIL_LIMIT = 500;
|
||||||
const MAX_DETAIL_LIMIT = 2000;
|
const MAX_DETAIL_LIMIT = 2000;
|
||||||
@ -151,22 +141,14 @@ const MAX_DETAIL_LIMIT = 2000;
|
|||||||
const STATE_STALE_MINUTES = 90;
|
const STATE_STALE_MINUTES = 90;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 不参与发货时效考核的快递(业务 2026-10-20 / 2026-10-27 指定):
|
* 不参与发货时效考核的快递(业务 2026-10-20 指定):
|
||||||
* 自提 —— 客户自己来拉,仓库不负责派送
|
* 自提 —— 客户自己来拉,仓库不负责派送
|
||||||
* 中铁 —— 中铁物流(大件专线)
|
* 中铁 —— 中铁物流(大件专线)
|
||||||
* 壹米滴答 —— 专线物流(大件)
|
* 壹米滴答 —— 专线物流(大件)
|
||||||
* 武进安能 —— 加盟制快运网点,揽收与在途都不受仓库控制(业务 2026-10-27 追加)
|
|
||||||
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
|
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
|
||||||
* 只影响「超时未发」判定,不影响「已发」判定。
|
* 只影响「超时未发」判定,不影响「已发」判定。
|
||||||
*
|
|
||||||
* 🔥 「武进安能」**必须精确**,别泛化成「安能」(与 warehouseLogisticsExceptionRegistry
|
|
||||||
* 同一口径,那里也写着这句)。实测近 30 天名字里带「安能」的有 8 个:
|
|
||||||
* 常州安能新 4207 / 武进安能 808 / 安能物流 110 / 名屋居安能快运 22 /
|
|
||||||
* 华韵安能 22 / 武进安能大件 15 / 盛强安能快运 5 / 雅柏安能 3
|
|
||||||
* 业务 2026-10-27 只要求剔除「武进安能」(含「武进安能大件」,因为包含匹配会一起命中),
|
|
||||||
* 其余安能网点(尤其「常州安能新」单量最大)**照旧考核**。
|
|
||||||
*/
|
*/
|
||||||
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答', '武进安能'];
|
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。
|
* 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。
|
||||||
@ -195,34 +177,6 @@ function clearShipTimelinessCache() {
|
|||||||
resultCache = { key: '', at: 0, data: null };
|
resultCache = { key: '', at: 0, data: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 明细「出口截断」:缓存里存的是**全量**行,这里才按本次请求的 limit 切。
|
|
||||||
*
|
|
||||||
* 🔥 为什么缓存不按 limit 分键(2026-10-27 改):
|
|
||||||
* 旧写法把 limit 拼进缓存键 —— 页面用 limit=1000、导出要全量 2000,
|
|
||||||
* 两边永远互不命中,每点一次「导出」都要重查一遍视图(冷启动 40~60 秒)。
|
|
||||||
* 但行数根本不影响 SQL 成本(`loadPendingStates` 一次就取回全量候选),
|
|
||||||
* 只有最后那一次 slice 受影响。所以:**缓存存全量、出口截断**。
|
|
||||||
* (WLE 的核验缓存早就这么改了,同一个教训。)
|
|
||||||
*
|
|
||||||
* `detailTotal` / `heldTotal` 是截断前的真实总数 —— 页面的「共 N 单」必须用它,
|
|
||||||
* 不能用 `orders.length`(截断后就是 limit,等于把「已截断」写成「一共就这么多」)。
|
|
||||||
*/
|
|
||||||
function withDetailLimit(payload, limit) {
|
|
||||||
const allOrders = Array.isArray(payload?.orders) ? payload.orders : [];
|
|
||||||
const allHeld = Array.isArray(payload?.heldOrders) ? payload.heldOrders : [];
|
|
||||||
const cap = Number.isFinite(limit) ? Math.max(0, limit) : allOrders.length;
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
orders: allOrders.slice(0, cap),
|
|
||||||
heldOrders: allHeld.slice(0, cap),
|
|
||||||
truncated: allOrders.length > cap,
|
|
||||||
detailLimit: Math.min(cap, allOrders.length),
|
|
||||||
detailTotal: allOrders.length,
|
|
||||||
heldTotal: allHeld.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把 datetime / Date / 字符串统一成 'YYYY-MM-DD HH:mm:ss'(东八区,不做时区换算) */
|
/** 把 datetime / Date / 字符串统一成 'YYYY-MM-DD HH:mm:ss'(东八区,不做时区换算) */
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
@ -270,7 +224,7 @@ async function loadPendingStates(round) {
|
|||||||
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
|
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
|
||||||
// 就是库里存的北京时间,不会偏移 8 小时。
|
// 就是库里存的北京时间,不会偏移 8 小时。
|
||||||
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
||||||
e.order_status_name, e.aftersale_status_name, e.logistic_status_name,
|
e.order_status_name, e.logistic_status_name,
|
||||||
e.logistic_id, e.logistic_company_name,
|
e.logistic_id, e.logistic_company_name,
|
||||||
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
|
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
|
||||||
e.send_time, e.query_time
|
e.send_time, e.query_time
|
||||||
@ -339,8 +293,7 @@ async function backfillOrderFacts(oids) {
|
|||||||
const results = await mapLimit(batches, 4, async (batch) => {
|
const results = await mapLimit(batches, 4, async (batch) => {
|
||||||
try {
|
try {
|
||||||
return await cloudDatabase.querySlow(
|
return await cloudDatabase.querySlow(
|
||||||
`SELECT o_id, created, status, send_date, is_merge, is_split,
|
`SELECT o_id, created, status, send_date, is_merge, is_split
|
||||||
CASE WHEN ${VOID_LABEL_SQL} THEN 1 ELSE 0 END AS presaleVoid
|
|
||||||
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
|
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
|
||||||
{},
|
{},
|
||||||
{ connectRequestTimeoutMs: 300000 }
|
{ connectRequestTimeoutMs: 300000 }
|
||||||
@ -361,15 +314,10 @@ async function backfillOrderFacts(oids) {
|
|||||||
createdAt: formatDateTime(r.created),
|
createdAt: formatDateTime(r.created),
|
||||||
status,
|
status,
|
||||||
sendDate,
|
sendDate,
|
||||||
// 作废(硬):被合并 / 被拆分 / 已取消 —— 这单不会自己发货,状态分类前就剔除
|
// 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货
|
||||||
void: VOID_ORDER_STATUS.includes(status)
|
void: VOID_ORDER_STATUS.includes(status)
|
||||||
|| Number(r.is_merge) === 1
|
|| Number(r.is_merge) === 1
|
||||||
|| Number(r.is_split) === 1,
|
|| Number(r.is_split) === 1,
|
||||||
// 作废(软):问题单 Question / 标签含「发货前售后」。
|
|
||||||
// ⚠️ 只能作用于「发货中」分支(见主循环),否则状态表「异常」的单
|
|
||||||
// 在订单表常也是 Question,会被一起吞掉 —— 实测 heldCount 472 → 10。
|
|
||||||
question: status === VOID_QUESTION_STATUS,
|
|
||||||
presaleVoid: Number(r.presaleVoid) === 1,
|
|
||||||
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
|
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
|
||||||
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
|
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
|
||||||
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
|
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
|
||||||
@ -413,11 +361,7 @@ async function getShipTimeliness(options = {}) {
|
|||||||
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
|
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
|
||||||
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
|
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
|
||||||
const now = options.now instanceof Date ? options.now : registry.beijingNow();
|
const now = options.now instanceof Date ? options.now : registry.beijingNow();
|
||||||
// 导出(fullDetail)要**全部**行,不受页面展示上限约束;
|
const limit = Math.min(
|
||||||
// 页面 / 钉钉推送走普通 limit(钉钉只列前 N 条,页面截断提示)
|
|
||||||
const fullDetail = options.fullDetail === true
|
|
||||||
|| ['1', 'true', 'yes'].includes(String(options.fullDetail || '').toLowerCase());
|
|
||||||
const limit = fullDetail ? Infinity : Math.min(
|
|
||||||
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||||
MAX_DETAIL_LIMIT
|
MAX_DETAIL_LIMIT
|
||||||
);
|
);
|
||||||
@ -465,9 +409,7 @@ async function getShipTimeliness(options = {}) {
|
|||||||
orders: [],
|
orders: [],
|
||||||
heldOrders: [],
|
heldOrders: [],
|
||||||
truncated: false,
|
truncated: false,
|
||||||
detailLimit: Number.isFinite(limit) ? limit : 0,
|
detailLimit: limit,
|
||||||
detailTotal: 0,
|
|
||||||
heldTotal: 0,
|
|
||||||
detailDegraded: false,
|
detailDegraded: false,
|
||||||
freshness: null,
|
freshness: null,
|
||||||
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
||||||
@ -475,16 +417,14 @@ async function getShipTimeliness(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时
|
// ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时
|
||||||
// 先看缓存:同一轮次 + 同一天,5 分钟内直接复用(存全量,出口再按 limit 切)
|
// 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用
|
||||||
const force = options.force === true
|
const force = options.force === true
|
||||||
|| ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase());
|
|| ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase());
|
||||||
// 🔥 缓存键只含「轮次 + 日期」,**不含 limit**(原因见 withDetailLimit 的注释):
|
const cacheKey = `${round.key}|${round.date}|${limit}`;
|
||||||
// 页面、钉钉推送、导出三方共用同一份缓存,导出的不会再重查一遍视图。
|
|
||||||
const cacheKey = `${round.key}|${round.date}`;
|
|
||||||
if (!force && resultCache.data && resultCache.key === cacheKey
|
if (!force && resultCache.data && resultCache.key === cacheKey
|
||||||
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
|
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
|
||||||
return {
|
return {
|
||||||
...withDetailLimit(resultCache.data, limit),
|
...resultCache.data,
|
||||||
cached: true,
|
cached: true,
|
||||||
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
|
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
|
||||||
};
|
};
|
||||||
@ -506,7 +446,7 @@ async function getShipTimeliness(options = {}) {
|
|||||||
const orderFacts = await backfillOrderFacts(oids);
|
const orderFacts = await backfillOrderFacts(oids);
|
||||||
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
||||||
let voidCount = 0;
|
let voidCount = 0;
|
||||||
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答 / 武进安能),写进 summary 便于对账
|
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账
|
||||||
let carrierSkipped = 0;
|
let carrierSkipped = 0;
|
||||||
|
|
||||||
// ④ 分桶
|
// ④ 分桶
|
||||||
@ -545,12 +485,10 @@ async function getShipTimeliness(options = {}) {
|
|||||||
// SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉
|
// SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉
|
||||||
if (IGNORED_STATES.includes(state)) continue;
|
if (IGNORED_STATES.includes(state)) continue;
|
||||||
|
|
||||||
// 🔥 不考核的快递(业务 2026-10-20 定 3 个,2026-10-27 追加武进安能)——
|
// 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 ——
|
||||||
// 走这几种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
|
// 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
|
||||||
// 中铁 / 壹米滴答是专线大件、武进安能是加盟制快运网点,
|
// 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。
|
||||||
// 揽收与在途都不受仓库控制)。
|
|
||||||
// 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。
|
// 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。
|
||||||
// ⚠️ 「武进安能」别泛化成「安能」:常州安能新等网点仍要考核。
|
|
||||||
const carrier = String(row.logistic_company_name || '').trim();
|
const carrier = String(row.logistic_company_name || '').trim();
|
||||||
const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k));
|
const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k));
|
||||||
if (hitCarrier) {
|
if (hitCarrier) {
|
||||||
@ -631,19 +569,6 @@ async function getShipTimeliness(options = {}) {
|
|||||||
|
|
||||||
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
|
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
|
||||||
|
|
||||||
// 🔥 2026-10-22:状态表说「发货中」,但订单表/售后维度已有作废证据 → 不考核。
|
|
||||||
// ⚠️ 必须放在「发货中」之后:订单表 Question 的单里有一大批在状态表是「异常」,
|
|
||||||
// 若在状态分类前判,会把 462 单「异常待处理」从 heldOrders 里吞掉。
|
|
||||||
// 三类证据(业务 2026-10-22 确认「聚水潭后台已作废却仍算超时」):
|
|
||||||
// · 状态表 aftersale_status_name =「已作废」(售后维度最权威)
|
|
||||||
// · 订单表 status = Question(聚水潭后台显示「已作废/问题单」)—— 9823980 / 9880810
|
|
||||||
// · 订单表 labels 含「发货前售后」(客户发货前退款/取消)—— 9823980 也带
|
|
||||||
if (String(row.aftersale_status_name || '').trim() === AFTERSALE_VOID_STATUS
|
|
||||||
|| (fact && (fact.question || fact.presaleVoid))) {
|
|
||||||
voidCount += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
|
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
|
||||||
if (!benchmarkAt) {
|
if (!benchmarkAt) {
|
||||||
bucket.heldCount += 1;
|
bucket.heldCount += 1;
|
||||||
@ -676,10 +601,8 @@ async function getShipTimeliness(options = {}) {
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ⚠️ 这里**不再按 limit 切**:payload 存全量进缓存,出口由 withDetailLimit 截断。
|
const detailRows = pendingRows.slice(0, limit);
|
||||||
// 以前在这里 slice,缓存里就只有前 limit 行 —— 导出拿不到全部,且 limit 一变就重查。
|
const heldDetail = heldRows.slice(0, limit);
|
||||||
const detailRows = pendingRows;
|
|
||||||
const heldDetail = heldRows;
|
|
||||||
|
|
||||||
const orders = detailRows.map((x) => {
|
const orders = detailRows.map((x) => {
|
||||||
const r = x.row;
|
const r = x.row;
|
||||||
@ -826,14 +749,13 @@ async function getShipTimeliness(options = {}) {
|
|||||||
},
|
},
|
||||||
summary,
|
summary,
|
||||||
centers,
|
centers,
|
||||||
// ⚠️ 全量(未截断);出口由 withDetailLimit 按 limit 切,并回填 detailTotal / heldTotal
|
|
||||||
orders,
|
orders,
|
||||||
// 不考核的快递(前端文案直接用,避免两边各写一份)
|
// 不考核的快递(前端文案直接用,避免两边各写一份)
|
||||||
excludedCarriers: EXCLUDED_CARRIERS,
|
excludedCarriers: EXCLUDED_CARRIERS,
|
||||||
// 不计考核清单(待审核 / 异常)—— 与 orders 互斥
|
// 不计考核清单(待审核 / 异常)—— 与 orders 互斥
|
||||||
heldOrders,
|
heldOrders,
|
||||||
truncated: false,
|
truncated: pendingRows.length > orders.length,
|
||||||
detailLimit: detailRows.length,
|
detailLimit: limit,
|
||||||
detailDegraded: false,
|
detailDegraded: false,
|
||||||
freshness,
|
freshness,
|
||||||
cached: false,
|
cached: false,
|
||||||
@ -842,88 +764,12 @@ async function getShipTimeliness(options = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
resultCache = { key: cacheKey, at: Date.now(), data: payload };
|
resultCache = { key: cacheKey, at: Date.now(), data: payload };
|
||||||
return withDetailLimit(payload, limit);
|
return payload;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出明细行(纯函数,便于单测)。
|
|
||||||
*
|
|
||||||
* 口径必须与页面明细**完全一致** —— 所以直接把 `getShipTimeliness().orders` 摊平,
|
|
||||||
* 不重新查库、不重新过滤,否则导出的行和页面上看到的会对不上。
|
|
||||||
*
|
|
||||||
* 比页面多带两列「快递公司 / 运单号」:业务拿 Excel 去追单时要能直接看到
|
|
||||||
* 是哪个快递、有没有打单(有运单号 = 已打单未揽收)。
|
|
||||||
*
|
|
||||||
* @param {Array} orders service 返回的 orders(已经是考核口径内的超时未发单)
|
|
||||||
* @returns {Array<object>} 字段名与前端 EXPORT_COLUMNS 的 dataIndex 一一对应
|
|
||||||
*/
|
|
||||||
function buildShipTimelinessExportRows(orders = []) {
|
|
||||||
const list = Array.isArray(orders) ? orders : [];
|
|
||||||
// 一律转字符串:单号是数字时 Excel 会显示成科学计数法(5127…E+18),业务没法拿它去搜单
|
|
||||||
const text = (v) => (v === null || v === undefined ? '' : String(v));
|
|
||||||
return list.map((o) => ({
|
|
||||||
oId: text(o?.oId),
|
|
||||||
soId: text(o?.soId),
|
|
||||||
shopName: text(o?.shopName),
|
|
||||||
// centerKey 只用于前端「按选中仓过滤」,不进导出列
|
|
||||||
centerKey: text(o?.centerKey),
|
|
||||||
centerName: text(o?.centerName),
|
|
||||||
warehouseName: text(o?.warehouseName),
|
|
||||||
logisticCompany: text(o?.logisticCompany),
|
|
||||||
logisticId: text(o?.logisticId),
|
|
||||||
logisticStatus: text(o?.logisticStatus),
|
|
||||||
state: text(o?.state),
|
|
||||||
sendTime: text(o?.sendTime),
|
|
||||||
auditTime: text(o?.auditTime),
|
|
||||||
payTime: text(o?.payTime),
|
|
||||||
benchmarkTime: text(o?.benchmarkTime),
|
|
||||||
benchmarkSource: text(o?.benchmarkSource),
|
|
||||||
overdueMinutes: Number(o?.overdueMinutes) || 0,
|
|
||||||
waitedHours: Number(o?.waitedHours) || 0,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「导出明细」的数据源:**不分页、不截断**。
|
|
||||||
*
|
|
||||||
* 与 `/list` 共用 `getShipTimeliness`(同一份缓存 → 页面刚刷过就是秒回),
|
|
||||||
* 唯一区别是 `fullDetail: true` 让出口不按 limit 切。
|
|
||||||
*/
|
|
||||||
async function listShipTimelinessExportRows(query = {}) {
|
|
||||||
const payload = await getShipTimeliness({
|
|
||||||
round: query.round,
|
|
||||||
force: query.force,
|
|
||||||
// now 只是给单测注入固定时刻用的(HTTP 上传不进来也不会解析字符串);
|
|
||||||
// 生产走 beijingNow(),见 getShipTimeliness 里的时区说明
|
|
||||||
now: query.now,
|
|
||||||
fullDetail: true,
|
|
||||||
});
|
|
||||||
const rows = buildShipTimelinessExportRows(payload.orders);
|
|
||||||
return {
|
|
||||||
rows,
|
|
||||||
total: rows.length,
|
|
||||||
notStarted: payload.notStarted === true,
|
|
||||||
date: payload.date,
|
|
||||||
round: payload.round,
|
|
||||||
deadline: payload.deadline,
|
|
||||||
generatedAt: payload.generatedAt,
|
|
||||||
summary: payload.summary || emptySummary(),
|
|
||||||
excludedCarriers: payload.excludedCarriers || EXCLUDED_CARRIERS,
|
|
||||||
freshness: payload.freshness || null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getShipTimeliness,
|
getShipTimeliness,
|
||||||
listShipTimelinessExportRows,
|
|
||||||
clearShipTimelinessCache,
|
clearShipTimelinessCache,
|
||||||
DEFAULT_DETAIL_LIMIT,
|
DEFAULT_DETAIL_LIMIT,
|
||||||
MAX_DETAIL_LIMIT,
|
MAX_DETAIL_LIMIT,
|
||||||
// 钉钉推送正文要列「剔除了哪几个快递」,直接复用服务层的清单,别在 job 里再抄一份
|
|
||||||
EXCLUDED_CARRIERS,
|
|
||||||
_private: {
|
|
||||||
EXCLUDED_CARRIERS,
|
|
||||||
buildShipTimelinessExportRows,
|
|
||||||
withDetailLimit,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -144,16 +144,12 @@ test('状态为空时按运单号区分:有号=未揽收,无号=无物流单
|
|||||||
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
|
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
|
||||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||||
|
|
||||||
function stubDb(rows, options = {}) {
|
function stubDb(rows) {
|
||||||
const calls = { times: 0, sql: [], params: [] };
|
const calls = { times: 0, sql: [], params: [] };
|
||||||
const handler = async (sql, params) => {
|
const handler = async (sql, params) => {
|
||||||
calls.times += 1;
|
calls.times += 1;
|
||||||
calls.sql.push(String(sql));
|
calls.sql.push(String(sql));
|
||||||
calls.params.push(params || {});
|
calls.params.push(params || {});
|
||||||
// 售后拦截判定会额外查两张售后表 —— 按表名分流,别把物流行当成售后行
|
|
||||||
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
|
||||||
return options.interceptRows || [];
|
|
||||||
}
|
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
require.cache[dbPath] = {
|
require.cache[dbPath] = {
|
||||||
@ -210,9 +206,9 @@ const rows = [
|
|||||||
send_time: '2026-10-20 11:00:00' }),
|
send_time: '2026-10-20 11:00:00' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
async function withStub(fn, stubRows = rows, options = {}) {
|
async function withStub(fn, stubRows = rows) {
|
||||||
const realDb = require.cache[dbPath];
|
const realDb = require.cache[dbPath];
|
||||||
const calls = stubDb(stubRows, options);
|
const calls = stubDb(stubRows);
|
||||||
delete require.cache[svcPath];
|
delete require.cache[svcPath];
|
||||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||||
try {
|
try {
|
||||||
@ -280,20 +276,18 @@ test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async
|
|||||||
|
|
||||||
test('结果缓存生效,force=1 绕过缓存重查', async () => {
|
test('结果缓存生效,force=1 绕过缓存重查', async () => {
|
||||||
await withStub(async (svc, calls) => {
|
await withStub(async (svc, calls) => {
|
||||||
// 只数主查询:售后拦截判定会额外查两张售后表,别把它们的次数算进来
|
|
||||||
const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length;
|
|
||||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||||
const first = await svc.getLogisticsExceptions(opts);
|
const first = await svc.getLogisticsExceptions(opts);
|
||||||
assert.equal(first.cached, false);
|
assert.equal(first.cached, false);
|
||||||
assert.equal(mainQueries(), 1);
|
assert.equal(calls.times, 1);
|
||||||
|
|
||||||
const second = await svc.getLogisticsExceptions(opts);
|
const second = await svc.getLogisticsExceptions(opts);
|
||||||
assert.equal(second.cached, true);
|
assert.equal(second.cached, true);
|
||||||
assert.equal(mainQueries(), 1, '第二次应命中缓存');
|
assert.equal(calls.times, 1, '第二次应命中缓存');
|
||||||
|
|
||||||
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
|
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
|
||||||
assert.equal(third.cached, false);
|
assert.equal(third.cached, false);
|
||||||
assert.equal(mainQueries(), 2, 'force=1 必须真查库');
|
assert.equal(calls.times, 2, 'force=1 必须真查库');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -326,39 +320,19 @@ test('导出:放开明细上限、返回全量异常(不受列表 2000 上
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('缓存只按核验日:页面加载后导出直接命中,不再重算整轮', async () => {
|
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => {
|
||||||
await withStub(async (svc, calls) => {
|
await withStub(async (svc, calls) => {
|
||||||
const mainQueries = () => calls.sql.filter((s) => /jst_order_express_details/u.test(s)).length;
|
|
||||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||||
|
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||||
|
assert.equal(calls.times, 1);
|
||||||
|
|
||||||
const list = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
|
||||||
assert.equal(list.cached, false, '页面首次应查库');
|
await svc.getLogisticsExceptionExportRows(opts);
|
||||||
assert.equal(mainQueries(), 1);
|
assert.equal(calls.times, 2, '导出是首次查,应真查库');
|
||||||
|
|
||||||
// 导出 limit=5000 与页面 1000 不同,但扫库成本一样 → 必须复用同一份全量缓存
|
|
||||||
// (2026-10-23 修:以前键里带 limit,这里会再扫一遍,生产实测多花 317s)
|
|
||||||
const exp = await svc.getLogisticsExceptionExportRows(opts);
|
|
||||||
assert.equal(mainQueries(), 1, '导出不该重新扫库');
|
|
||||||
assert.equal(exp.detailLimit, 5000);
|
|
||||||
assert.equal(exp.rows.length, list.summary.exceptionPackages, '导出仍要拿到全部明细');
|
|
||||||
|
|
||||||
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||||
assert.equal(listAgain.cached, true, '页面那份缓存应仍在');
|
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
|
||||||
assert.equal(mainQueries(), 1);
|
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('缓存存全量:先按小 limit 取,后面大 limit 仍拿到完整明细', async () => {
|
|
||||||
await withStub(async (svc) => {
|
|
||||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
|
||||||
const small = await svc.getLogisticsExceptions({ ...opts, limit: 2 });
|
|
||||||
assert.equal(small.orders.length, 2);
|
|
||||||
assert.equal(small.truncated, true);
|
|
||||||
|
|
||||||
const big = await svc.getLogisticsExceptions({ ...opts, limit: 5000, maxLimit: 5000 });
|
|
||||||
assert.equal(big.cached, true, '应命中同一份缓存');
|
|
||||||
assert.equal(big.orders.length, big.summary.exceptionPackages, '缓存不能只存被截断的那 2 条');
|
|
||||||
assert.equal(big.truncated, false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -542,45 +516,6 @@ test('刷单的包裹不进分母也不算异常', async () => {
|
|||||||
}, mixed);
|
}, mixed);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('转仓标签识别:转x00005(含大小写/空格)都认,转空包不认', () => {
|
|
||||||
assert.equal(registry.matchTransferWarehouse('["京喜","转x00005"]'), '转x00005');
|
|
||||||
assert.equal(registry.matchTransferWarehouse('["转X00005"]'), '转X00005');
|
|
||||||
assert.equal(registry.matchTransferWarehouse('["转 x00005"]'), '转x00005');
|
|
||||||
// 后续可能出现别的目标仓编码
|
|
||||||
assert.equal(registry.matchTransferWarehouse('["转x00006"]'), '转x00006');
|
|
||||||
// 反例:转空包是信誉单(发空包),不是转仓代发
|
|
||||||
assert.equal(registry.matchTransferWarehouse('["转空包发货"]'), null);
|
|
||||||
assert.equal(registry.matchTransferWarehouse('["京喜","线上发货"]'), null);
|
|
||||||
assert.equal(registry.matchTransferWarehouse(''), null);
|
|
||||||
assert.equal(registry.matchTransferWarehouse(null), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('转仓代发整单不计:不算异常,也不进原仓发货分母', async () => {
|
|
||||||
const mixed = [
|
|
||||||
// 30:转x00005 · 未揽收 → 崔桥下单、曹县代发,货不是从原仓走的,整单不计
|
|
||||||
mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '未揽收', logistic_time: null,
|
|
||||||
logistic_company_name: '曹县韵达小件', order_labels: '["京喜","转x00005"]' }),
|
|
||||||
// 31:同样「未揽收」但没转仓标签 → 必须留下
|
|
||||||
mk({ inner_order_id: 31, so_id: 'SO-31', logistic_status_name: '未揽收', logistic_time: null,
|
|
||||||
order_labels: '["京喜"]' }),
|
|
||||||
];
|
|
||||||
await withStub(async (svc) => {
|
|
||||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
|
||||||
assert.deepEqual(data.orders.map((o) => o.oId), ['31'], '只留非转仓那单');
|
|
||||||
assert.equal(data.exclusions.transferOrders, 1);
|
|
||||||
assert.equal(data.exclusions.transferPackages, 1);
|
|
||||||
assert.equal(data.exclusions.transferReasons['转x00005'], 1);
|
|
||||||
assert.equal(data.summary.exceptionPackages, 1, '转仓单不计异常');
|
|
||||||
// 🔥 业务 2026-10-24:货不从原仓走,原仓发货量也不该算
|
|
||||||
assert.equal(data.summary.packageCount, 1, '转仓单不进原仓发货分母');
|
|
||||||
assert.equal(data.summary.shippedOrders, 1, '转仓单不算原仓发货单');
|
|
||||||
// 仓维度:崔桥仓发货单数也只剩 31 那单(30 是转仓、不计)
|
|
||||||
const center = data.centers.find((c) => c.key === 'CQ');
|
|
||||||
assert.equal(center.shippedOrders, 1, '崔桥仓发货单数不应包含转仓单');
|
|
||||||
assert.equal(center.exceptionOrders, 1, '崔桥仓异常只算 31');
|
|
||||||
}, mixed);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
||||||
await withStub(async (svc) => {
|
await withStub(async (svc) => {
|
||||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
|
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
|
||||||
@ -588,87 +523,5 @@ test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
|||||||
assert.ok(Array.isArray(data.exclusions.carriers));
|
assert.ok(Array.isArray(data.exclusions.carriers));
|
||||||
assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
|
assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
|
||||||
assert.equal(data.exclusions.carrierOrders, 0);
|
assert.equal(data.exclusions.carrierOrders, 0);
|
||||||
assert.equal(data.exclusions.interceptOrders, 0, '未开跑也要给出拦截剔除字段');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ⑦ 现场拦截(售后已签收)不算转运异常(业务 2026-10-23)─────────────
|
|
||||||
test('现场拦截判据:签收单号非空 + 实退数量>0 才剔,货还在路上的不剔', async () => {
|
|
||||||
const mixed = [
|
|
||||||
// 30:已揽收未中转 → 售后单有签收单号 + 实退 1 件 → 已签收,必须剔
|
|
||||||
mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '已揽收',
|
|
||||||
logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00',
|
|
||||||
last_info: '中通快递 已收取快件' }),
|
|
||||||
// 31:同形态但售后 r_qty=0(货还在路上)→ **不能剔**,仍是真异常
|
|
||||||
mk({ inner_order_id: 31, so_id: 'SO-31', logistic_status_name: '已揽收',
|
|
||||||
logistic_time: '2026-10-20 02:00:00', send_time: '2026-10-20 01:00:00',
|
|
||||||
last_info: '中通快递 已收取快件' }),
|
|
||||||
// 32:未揽收 + 售后已签收 → 同样剔(不挑异常类型)
|
|
||||||
mk({ inner_order_id: 32, so_id: 'SO-32', logistic_status_name: '未揽收', logistic_time: null }),
|
|
||||||
// 33:正常已签收 → 本来就不算异常,不受影响
|
|
||||||
mk({ inner_order_id: 33, so_id: 'SO-33' }),
|
|
||||||
];
|
|
||||||
await withStub(async (svc, calls) => {
|
|
||||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
|
||||||
|
|
||||||
// 30 / 32 被剔;31 仍是异常;33 本来正常
|
|
||||||
assert.deepEqual(data.orders.map((o) => o.oId), ['31']);
|
|
||||||
assert.equal(data.summary.exceptionOrders, 1);
|
|
||||||
// 🔥 发货分母不能掉:货确实发出去了,只是物流环节已结束
|
|
||||||
assert.equal(data.summary.shippedOrders, 4, '拦截单仍算发货分母,否则异常率被放大');
|
|
||||||
assert.equal(data.summary.exceptionRate, 25, '1 / 4');
|
|
||||||
|
|
||||||
assert.equal(data.exclusions.interceptPackages, 2);
|
|
||||||
assert.equal(data.exclusions.interceptOrders, 2);
|
|
||||||
assert.equal(data.exclusions.interceptReasons['已揽收未中转'], 1);
|
|
||||||
assert.equal(data.exclusions.interceptReasons['未揽收'], 1);
|
|
||||||
|
|
||||||
// 判据 SQL:两张店铺表都要查、都要 join 明细、都必须卡 l_id 与 r_qty
|
|
||||||
const asSql = calls.sql.filter((s) => /jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(s));
|
|
||||||
assert.equal(asSql.length, 2, `售后表要查两套(店铺互斥),实际 ${asSql.length}`);
|
|
||||||
assert.ok(asSql.some((s) => /jst_returnAndRefundItem/u.test(s)), '必须关联明细表拿实退数量');
|
|
||||||
assert.ok(asSql.some((s) => /jst_qmcust_refundlist_item/u.test(s)), '客服售后单也要关联明细表');
|
|
||||||
for (const s of asSql) {
|
|
||||||
assert.match(s, /l_id[\s\S]*<> ''/u, '必须有「签收单号非空」条件');
|
|
||||||
assert.match(s, /SUM\(ISNULL\(it\.r_qty, 0\)\) > 0/u, '必须有「实退数量 > 0」条件');
|
|
||||||
assert.doesNotMatch(s, /it\.qty[\s\S]*> 0/u, '不能拿申请数量 qty 当实退数量');
|
|
||||||
}
|
|
||||||
}, mixed, { interceptRows: [{ o_id: 30 }, { o_id: 32 }] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('售后表查询失败时降级:不剔单、不炸页面', async () => {
|
|
||||||
const mixed = [
|
|
||||||
mk({ inner_order_id: 40, so_id: 'SO-40', logistic_status_name: '未揽收', logistic_time: null }),
|
|
||||||
];
|
|
||||||
const realDb = require.cache[dbPath];
|
|
||||||
const calls = { times: 0, sql: [] };
|
|
||||||
require.cache[dbPath] = {
|
|
||||||
id: dbPath,
|
|
||||||
filename: dbPath,
|
|
||||||
loaded: true,
|
|
||||||
exports: {
|
|
||||||
query: async (sql) => {
|
|
||||||
calls.sql.push(String(sql));
|
|
||||||
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
|
||||||
throw new Error('售后表超时');
|
|
||||||
}
|
|
||||||
return mixed;
|
|
||||||
},
|
|
||||||
querySlow: async () => mixed,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
delete require.cache[svcPath];
|
|
||||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
|
||||||
const svc = require(svcPath);
|
|
||||||
svc.clearLogisticsExceptionCache();
|
|
||||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
|
||||||
assert.equal(data.summary.exceptionOrders, 1, '售后表挂了就该照常报异常,别把异常吞掉');
|
|
||||||
assert.equal(data.exclusions.interceptOrders, 0);
|
|
||||||
} finally {
|
|
||||||
if (realDb) require.cache[dbPath] = realDb;
|
|
||||||
else delete require.cache[dbPath];
|
|
||||||
delete require.cache[svcPath];
|
|
||||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@ -207,13 +207,6 @@ function stubDb(dbPath, scenario = 'default') {
|
|||||||
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
||||||
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
|
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
|
||||||
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
||||||
// ── 2026-10-22 作废单三类形态 ──
|
|
||||||
// 38:订单表 Question(聚水潭「已作废/问题单」)
|
|
||||||
38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }),
|
|
||||||
// 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转)
|
|
||||||
39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }),
|
|
||||||
// 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常
|
|
||||||
40: f('jst_Orders', { created: '2026-10-20 11:10:00' }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
|
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
|
||||||
@ -234,8 +227,6 @@ function stubDb(dbPath, scenario = 'default') {
|
|||||||
shop_name: '测试店',
|
shop_name: '测试店',
|
||||||
logistic_status_name: '',
|
logistic_status_name: '',
|
||||||
query_time: '2026-10-20 11:18:27',
|
query_time: '2026-10-20 11:18:27',
|
||||||
// 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到
|
|
||||||
aftersale_status_name: '暂无售后',
|
|
||||||
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
|
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
|
||||||
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
|
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
|
||||||
...o,
|
...o,
|
||||||
@ -331,31 +322,10 @@ function stubDb(dbPath, scenario = 'default') {
|
|||||||
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
|
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
|
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
|
||||||
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
|
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
|
||||||
// 41/42:🔥 武进安能(业务 2026-10-27 追加不考核)—— 两种实际写法都要剔掉。
|
|
||||||
// 42 是「武进安能大件」,靠包含匹配一起命中,别写成精确相等。
|
|
||||||
mk({ so_id: 'SO-41', inner_order_id: 41, warehouse_name: '简尚-崔桥工厂仓',
|
|
||||||
order_status_name: '发货中', pay_time: '2026-09-25 03:00:00',
|
|
||||||
last_oa_confirm_time: '2026-09-25 03:30:00', logistic_company_name: '武进安能' }),
|
|
||||||
mk({ so_id: 'SO-42', inner_order_id: 42, warehouse_name: '简尚-崔桥工厂仓',
|
|
||||||
order_status_name: '发货中', pay_time: '2026-09-25 04:00:00',
|
|
||||||
last_oa_confirm_time: '2026-09-25 04:30:00', logistic_company_name: '武进安能大件' }),
|
|
||||||
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
|
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
|
||||||
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
|
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
|
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
|
||||||
last_oa_confirm_time: '2026-09-22 09:05:00' }),
|
last_oa_confirm_time: '2026-09-22 09:05:00' }),
|
||||||
// ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)──
|
|
||||||
// 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810
|
|
||||||
mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓',
|
|
||||||
order_status_name: '发货中', pay_time: '2026-10-20 09:10:00',
|
|
||||||
last_oa_confirm_time: '2026-10-20 09:15:00' }),
|
|
||||||
// 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980
|
|
||||||
mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓',
|
|
||||||
order_status_name: '发货中', pay_time: '2026-10-20 10:10:00',
|
|
||||||
last_oa_confirm_time: '2026-10-20 10:15:00' }),
|
|
||||||
// 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除
|
|
||||||
mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓',
|
|
||||||
order_status_name: '发货中', aftersale_status_name: '已作废',
|
|
||||||
pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15:00' }),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
|
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
|
||||||
@ -422,7 +392,6 @@ function stubDb(dbPath, scenario = 'default') {
|
|||||||
send_date: x.send_date,
|
send_date: x.send_date,
|
||||||
is_merge: x.is_merge,
|
is_merge: x.is_merge,
|
||||||
is_split: x.is_split,
|
is_split: x.is_split,
|
||||||
presaleVoid: x.presaleVoid || 0,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -537,32 +506,20 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
|||||||
// 被排除的仓不能算
|
// 被排除的仓不能算
|
||||||
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
|
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
|
||||||
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
|
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
|
||||||
// 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废)
|
for (const id of ['9', '10', '11', '12']) {
|
||||||
for (const id of ['9', '10', '11', '12', '38', '39', '40']) {
|
|
||||||
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
|
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} 不该进挂起清单`);
|
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
|
||||||
}
|
}
|
||||||
// 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类)
|
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
|
||||||
assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数');
|
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
|
||||||
|
|
||||||
// 不考核快递:中铁物流、壹米滴答专线、武进安能必须整单跳过
|
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
|
||||||
// (业务 2026-10-20 定前两个,2026-10-27 追加武进安能)
|
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
|
||||||
assert.equal(data.summary.carrierSkipped, 4, '自提/中铁/壹米滴答/武进安能 要计入 carrierSkipped');
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!data.orders.some((o) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(o.soId)),
|
!data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)),
|
||||||
'不考核快递的单不能出现在超时明细里'
|
'不考核快递的单不能出现在超时明细里'
|
||||||
);
|
);
|
||||||
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答', '武进安能']);
|
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']);
|
||||||
// 🔥 「武进安能」只剔这一个网点,**不能**泛化成「安能」:
|
|
||||||
// 库里带「安能」的还有 常州安能新 / 安能物流 / 华韵安能 / 雅柏安能…(常州安能新单量最大)
|
|
||||||
assert.ok(
|
|
||||||
!svc._private.EXCLUDED_CARRIERS.some((k) => '常州安能新'.includes(k)),
|
|
||||||
'常州安能新必须照旧考核,别把关键词放大成「安能」'
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
svc._private.EXCLUDED_CARRIERS.some((k) => '武进安能大件'.includes(k)),
|
|
||||||
'武进安能大件要能被「武进安能」包含匹配命中'
|
|
||||||
);
|
|
||||||
|
|
||||||
// 不计考核清单
|
// 不计考核清单
|
||||||
assert.equal(data.heldOrders.length, 2);
|
assert.equal(data.heldOrders.length, 2);
|
||||||
@ -604,16 +561,13 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
|||||||
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
|
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
|
||||||
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
|
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
|
||||||
|
|
||||||
// 补创建时间:必须走两张订单表;6 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来)
|
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
|
||||||
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
|
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
|
||||||
for (const bs of calls.backfillSql) {
|
for (const bs of calls.backfillSql) {
|
||||||
assert.match(bs,
|
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
|
||||||
/SELECT o_id, created, status, send_date, is_merge, is_split,\s+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
|
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
|
||||||
`补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`);
|
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
|
||||||
assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u,
|
|
||||||
'多取一列就会把 0.2s 拖到 38s');
|
'多取一列就会把 0.2s 拖到 38s');
|
||||||
// labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文
|
|
||||||
assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠️ 窗口参数走 params,不在 SQL 里手写
|
// ⚠️ 窗口参数走 params,不在 SQL 里手写
|
||||||
@ -711,46 +665,3 @@ test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器
|
|||||||
assert.equal(noon.deadline, '2026-10-21 13:00:00');
|
assert.equal(noon.deadline, '2026-10-21 13:00:00');
|
||||||
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
|
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ⑨ 导出明细(2026-10-27 新增「导出明细」按钮的后端口径)──────────────────
|
|
||||||
// 关键约束:导出必须是**全量**,且不能因为导出又重扫一遍状态表 ——
|
|
||||||
// 所以缓存键不含 limit(存全量、出口截断)。这个用例专门锁这两点。
|
|
||||||
test('导出取数:不受 limit 截断,且与列表共用同一份缓存', withStubbedService(async (svc, { calls }) => {
|
|
||||||
// 列表按 limit=3 取(页面就是这样截断的)
|
|
||||||
const limited = await svc.getShipTimeliness({ now: at(18, 5), limit: 3 });
|
|
||||||
assert.equal(limited.orders.length, 3, 'limit=3 只给 3 行');
|
|
||||||
assert.equal(limited.truncated, true, '被截断必须打标记');
|
|
||||||
assert.equal(limited.detailTotal, 7, '截断后仍要给出真实总数(页面「共 N 单」用它)');
|
|
||||||
assert.equal(calls.state, 1, '第一次查询打了一次状态表');
|
|
||||||
|
|
||||||
// 导出:同一轮次同一天 → 必须命中同一份缓存,且拿到全部 7 行
|
|
||||||
const exported = await svc.listShipTimelinessExportRows({ round: 'AFTERNOON', now: at(18, 6) });
|
|
||||||
assert.equal(calls.state, 1, '导出不能再扫一遍状态表(旧实现把 limit 拼进缓存键,每次导出都重查)');
|
|
||||||
assert.equal(exported.total, 7, '导出必须是全部超时单,不能被列表的 limit 带跑');
|
|
||||||
assert.equal(exported.rows.length, 7);
|
|
||||||
// 导出列里业务要能直接看到快递 / 运单号(追单用),centerKey 供前端按仓过滤
|
|
||||||
for (const row of exported.rows) {
|
|
||||||
for (const key of ['oId', 'soId', 'shopName', 'centerKey', 'centerName',
|
|
||||||
'warehouseName', 'logisticCompany', 'logisticId', 'auditTime', 'overdueMinutes']) {
|
|
||||||
assert.ok(key in row, `导出缺字段 ${key}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 不考核快递的单不能出现在导出里(口径与页面一致)
|
|
||||||
assert.ok(!exported.rows.some((r) => ['SO-35', 'SO-36', 'SO-41', 'SO-42'].includes(r.soId)),
|
|
||||||
'不考核快递的单不能导出');
|
|
||||||
assert.deepEqual(exported.excludedCarriers, ['自提', '中铁', '壹米滴答', '武进安能']);
|
|
||||||
}));
|
|
||||||
|
|
||||||
test('导出行为纯函数:缺字段一律给空串,不导出 undefined', () => {
|
|
||||||
const service = require('../src/services/warehouseShipTimelinessService');
|
|
||||||
const rows = service._private.buildShipTimelinessExportRows([
|
|
||||||
{ oId: 1, soId: 'SO-1', overdueMinutes: null },
|
|
||||||
null,
|
|
||||||
]);
|
|
||||||
assert.equal(rows.length, 2, '空行也要占位,别让行号错位');
|
|
||||||
assert.equal(rows[0].oId, '1', '数字单号要转成字符串,避免 Excel 科学计数法');
|
|
||||||
assert.equal(rows[0].logisticCompany, '');
|
|
||||||
assert.equal(rows[0].overdueMinutes, 0, 'null 时长按 0 处理,别给 NaN');
|
|
||||||
assert.equal(rows[1].oId, '');
|
|
||||||
assert.doesNotMatch(JSON.stringify(rows), /undefined|null|NaN/u);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,294 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「仓储中心 24 小时发货时效」两轮核验完成后推钉钉群 —— 正文组装与调度回归测试
|
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
|
||||||
* 业务口径(2026-10-27):「13 点核验 7 点前的超时单,核验完成后通知;
|
|
||||||
* 18:00 核验的订单,核验完成后也通知。」⟹ 一天两推,各绑一个轮次。
|
|
||||||
*
|
|
||||||
* 这里只测五件容易悄悄坏掉的事:
|
|
||||||
* ① 两个轮次各自的推送时刻能不能正确命中(含「配成同一时刻」的边界);
|
|
||||||
* ② 群消息正文的数字与明细(业务照着这条消息追单,数字错了没人能发现);
|
|
||||||
* ③ 0 单超时也要推(群里看到「本轮 0 单」= 任务没挂);
|
|
||||||
* ④ 明细按仓保底 + 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
|
|
||||||
* ⑤ 去重键必须「日期 + 轮次」,一天两轮互不干扰。
|
|
||||||
*
|
|
||||||
* ⚠️ 本文件不连数据库、不发网络请求,纯逻辑测试。
|
|
||||||
*/
|
|
||||||
|
|
||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
|
|
||||||
const job = require('../src/jobs/warehouseShipTimelinessNotifyJob');
|
|
||||||
const settings = require('../src/services/warehouseShipTimelinessNotifySettings');
|
|
||||||
|
|
||||||
function sampleData(overrides = {}) {
|
|
||||||
return {
|
|
||||||
round: { key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEnd: '2026-10-27 07:00:00' },
|
|
||||||
deadline: '2026-10-27 13:00:00',
|
|
||||||
date: '2026-10-27',
|
|
||||||
window: { start: '2026-10-07 00:00:00', end: '2026-10-27 07:00:00', includeHistory: true, boundary: 'audit' },
|
|
||||||
summary: {
|
|
||||||
overdueUnshipped: 3,
|
|
||||||
overdueCenterCount: 2,
|
|
||||||
oldestDays: 4,
|
|
||||||
heldCount: 5,
|
|
||||||
voidCount: 12,
|
|
||||||
carrierSkipped: 2,
|
|
||||||
},
|
|
||||||
centers: [
|
|
||||||
{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 2, oldestDays: 4 },
|
|
||||||
{ key: 'HL2', name: '横林二仓', overdueUnshipped: 1, oldestDays: 1 },
|
|
||||||
{ key: 'SN', name: '睢宁仓', overdueUnshipped: 0, oldestDays: 0 },
|
|
||||||
],
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
oId: '9855123',
|
|
||||||
soId: '261021-301318827670255',
|
|
||||||
shopName: '京东旗舰店',
|
|
||||||
warehouseName: '简尚-崔桥工厂仓',
|
|
||||||
centerName: '崔桥工厂仓',
|
|
||||||
logisticCompany: '武进韵达',
|
|
||||||
auditTime: '2026-10-27 06:32:11',
|
|
||||||
overdueMinutes: 388,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
oId: '9855200',
|
|
||||||
soId: 'SO20261027001',
|
|
||||||
shopName: '天猫店',
|
|
||||||
warehouseName: '简尚-崔桥工厂仓',
|
|
||||||
centerName: '崔桥工厂仓',
|
|
||||||
logisticCompany: '常州安能新',
|
|
||||||
auditTime: '2026-10-26 09:05:00',
|
|
||||||
overdueMinutes: 2875,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
oId: '9855300',
|
|
||||||
soId: '',
|
|
||||||
shopName: '抖音店',
|
|
||||||
warehouseName: '简尚-横林二仓',
|
|
||||||
centerName: '横林二仓',
|
|
||||||
// 快递名为空 → 明细里要显示「—」,不能出现 undefined
|
|
||||||
logisticCompany: '',
|
|
||||||
auditTime: '',
|
|
||||||
overdueMinutes: 120,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
freshness: { stateMaxQuery: '2026-10-27 13:04:00', coversDeadline: true, stale: false },
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function config(overrides = {}) {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test',
|
|
||||||
webhookSecret: '',
|
|
||||||
noonSendTime: '13:05',
|
|
||||||
afternoonSendTime: '18:05',
|
|
||||||
maxDetailRows: 30,
|
|
||||||
atAll: false,
|
|
||||||
pageUrl: 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构造北京时间某个时刻(本机是东八区,直接 new Date(本地字段) 即可) */
|
|
||||||
function bj(year, month, day, hour, minute) {
|
|
||||||
return new Date(year, month - 1, day, hour, minute, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ① 两个轮次各自的推送时刻 ──────────────────────────────────────────────
|
|
||||||
test('两个轮次按各自配置的时刻命中', () => {
|
|
||||||
const cfg = config();
|
|
||||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON']);
|
|
||||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 18, 5)), ['AFTERNOON']);
|
|
||||||
// 非命中时刻一律不推(免得每分钟都轰炸一次)
|
|
||||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 6)), []);
|
|
||||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 12, 59)), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('两个时刻配成同一个 HH:mm 时,两轮都要推(不能只推第一轮)', () => {
|
|
||||||
const cfg = config({ noonSendTime: '13:05', afternoonSendTime: '13:05' });
|
|
||||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON', 'AFTERNOON']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补', () => {
|
|
||||||
const cfg = config();
|
|
||||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: true }), ['NOON']);
|
|
||||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 40), { catchUp: true }), []);
|
|
||||||
// 关掉补推时,错过就错过
|
|
||||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: false }), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── ② 正文内容 ────────────────────────────────────────────────────────────
|
|
||||||
test('正文包含轮次、超时单数、涉及仓数、最久天数', () => {
|
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
|
||||||
assert.match(built.text, /【仓储中心发货时效】13:00 轮/);
|
|
||||||
assert.match(built.text, /核验 07:00 前审核/);
|
|
||||||
assert.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/);
|
|
||||||
assert.equal(built.rowCount, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('按仓块只列超时 > 0 的仓,并按单数降序', () => {
|
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
|
||||||
assert.match(built.text, /按仓(超时单):/);
|
|
||||||
assert.match(built.text, /1\. 崔桥工厂仓 2 单(最久 4 天)/);
|
|
||||||
assert.match(built.text, /2\. 横林二仓 1 单(最久 1 天)/);
|
|
||||||
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('明细一行 = 仓|内部单号|线上单号|快递|审核 时间|已等', () => {
|
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
|
||||||
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分/);
|
|
||||||
// 无线上单号 → 「—」;无快递名 → 「—」;无审核时间 → 「未审核」;都不能出现 undefined
|
|
||||||
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|审核 未审核|已等 2 小时/);
|
|
||||||
assert.doesNotMatch(built.text, /undefined|NaN/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看是哪家快递)', () => {
|
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
|
||||||
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
|
|
||||||
assert.ok(line, '明细第 2 行不见了');
|
|
||||||
const parts = line.replace(/^\d+\.\s*/, '').split('|');
|
|
||||||
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => {
|
|
||||||
// 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单
|
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
|
||||||
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|审核 10-26 09:05|已等 1 天 23 小时/);
|
|
||||||
// 同一天的就只给 HH:mm,别啰嗦
|
|
||||||
assert.match(built.text, /审核 06:32/);
|
|
||||||
assert.doesNotMatch(built.text, /审核 10-27 06:32/);
|
|
||||||
// 直接测函数本身的跨天判定
|
|
||||||
assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32');
|
|
||||||
assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13');
|
|
||||||
assert.equal(job.auditWhen('', '2026-10-27'), '未审核');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
|
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
|
||||||
assert.match(built.text, /另有 5 单不计考核/);
|
|
||||||
assert.match(built.text, /已剔除 2 单不考核快递/);
|
|
||||||
assert.doesNotMatch(built.text, /⚠️/);
|
|
||||||
|
|
||||||
const stale = job.buildNotifyText(sampleData({
|
|
||||||
freshness: { stateMaxQuery: '2026-10-27 12:31:00', coversDeadline: false, stale: true },
|
|
||||||
}), { maxDetailRows: 30 });
|
|
||||||
assert.match(stale.text, /⚠️ 数据快照只到 12:31/);
|
|
||||||
assert.match(stale.text, /尚未覆盖本轮核验时刻 13:00/);
|
|
||||||
assert.match(stale.text, /超时单量偏大/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── ③ 0 单也要推 ──────────────────────────────────────────────────────────
|
|
||||||
test('本轮 0 单超时:发「全部按时发出」,不带空明细块', () => {
|
|
||||||
const built = job.buildNotifyText(sampleData({
|
|
||||||
summary: { overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, heldCount: 0, voidCount: 3, carrierSkipped: 0 },
|
|
||||||
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 0, oldestDays: 0 }],
|
|
||||||
orders: [],
|
|
||||||
}), { maxDetailRows: 30 });
|
|
||||||
assert.match(built.text, /超时未发货 0 单,涉及 0 个仓/);
|
|
||||||
assert.match(built.text, /本轮没有超时未发货的订单,全部按时发出/);
|
|
||||||
assert.doesNotMatch(built.text, /明细前/);
|
|
||||||
assert.doesNotMatch(built.text, /查看全部/);
|
|
||||||
assert.equal(built.rowCount, 0);
|
|
||||||
assert.equal(built.listedCount, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── ④ 明细额度:按仓保底 + 超长精简 ───────────────────────────────────────
|
|
||||||
test('明细额度按仓保底:一个仓吃不满额度,其它仓至少 1 条', () => {
|
|
||||||
const many = [];
|
|
||||||
for (let i = 0; i < 40; i += 1) {
|
|
||||||
many.push({ oId: `A${i}`, soId: '', centerName: '崔桥工厂仓', warehouseName: '简尚-崔桥工厂仓', overdueMinutes: 600 + i });
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 2; i += 1) {
|
|
||||||
many.push({ oId: `B${i}`, soId: '', centerName: '横林二仓', warehouseName: '简尚-横林二仓', overdueMinutes: 300 });
|
|
||||||
}
|
|
||||||
const picked = job.pickDetailRows(many, 6);
|
|
||||||
assert.equal(picked.length, 6);
|
|
||||||
// 每仓保底 1 条,剩余额度按各仓条数比例分(40:2 ⇒ 5:1)
|
|
||||||
assert.equal(picked.filter((row) => row.centerName === '横林二仓').length, 1);
|
|
||||||
assert.equal(picked.filter((row) => row.centerName === '崔桥工厂仓').length, 5);
|
|
||||||
// 组间按仓聚集:条数多的仓排前面,所以首行是崔桥
|
|
||||||
assert.equal(picked[0].centerName, '崔桥工厂仓');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('明细条数 0 = 只发汇总不带明细', () => {
|
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
|
|
||||||
assert.equal(built.listedCount, 0);
|
|
||||||
assert.match(built.text, /明细共 3 条,消息里不放/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('超长时从明细末尾往前砍,砍到能发为止', () => {
|
|
||||||
// 故意把线上单号拉长(真实数据没见过,但业务改过列宽/加字段后有可能),
|
|
||||||
// 保证 50 条明细 + 分仓块必然超过 20000 字节上限
|
|
||||||
const rows = [];
|
|
||||||
for (let i = 0; i < 300; i += 1) {
|
|
||||||
rows.push({
|
|
||||||
oId: `98${String(i).padStart(6, '0')}`,
|
|
||||||
soId: `SO-2026-10-27-${String(i).padStart(6, '0')}-${'X'.repeat(360)}`,
|
|
||||||
centerName: '崔桥工厂仓',
|
|
||||||
warehouseName: '简尚-崔桥工厂仓',
|
|
||||||
auditTime: '2026-10-27 06:32:11',
|
|
||||||
overdueMinutes: 400 + i,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const built = job.buildNotifyText(sampleData({
|
|
||||||
summary: { overdueUnshipped: 300, overdueCenterCount: 1, oldestDays: 4, heldCount: 0, voidCount: 0, carrierSkipped: 0 },
|
|
||||||
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 300, oldestDays: 4 }],
|
|
||||||
orders: rows,
|
|
||||||
}), { maxDetailRows: 50 });
|
|
||||||
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, `bytes=${built.bytes} 超上限`);
|
|
||||||
assert.equal(built.truncated, true);
|
|
||||||
assert.ok(built.listedCount < 50);
|
|
||||||
assert.match(built.text, /其余 \d+ 条见页面/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── ⑤ 去重键 ──────────────────────────────────────────────────────────────
|
|
||||||
test('去重键是「日期|轮次」,两轮互不覆盖', () => {
|
|
||||||
const noon = settings.buildPushSlot('2026-10-27', 'NOON');
|
|
||||||
const afternoon = settings.buildPushSlot('2026-10-27', 'AFTERNOON');
|
|
||||||
assert.equal(noon, '2026-10-27|NOON');
|
|
||||||
assert.equal(afternoon, '2026-10-27|AFTERNOON');
|
|
||||||
assert.notEqual(noon, afternoon);
|
|
||||||
// 小写也归一
|
|
||||||
assert.equal(settings.buildPushSlot('2026-10-27', 'noon'), '2026-10-27|NOON');
|
|
||||||
assert.equal(settings.buildPushSlot('', 'NOON'), '');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('推送时刻归一化:非法值退回默认,不抛错', () => {
|
|
||||||
assert.equal(settings.normalizeSendTime('13:05'), '13:05');
|
|
||||||
assert.equal(settings.normalizeSendTime('8:5'), '13:05');
|
|
||||||
assert.equal(settings.normalizeSendTime('25:00'), '13:05');
|
|
||||||
assert.equal(settings.normalizeSendTime(''), '13:05');
|
|
||||||
assert.equal(settings.normalizeSendTime('', '18:05'), '18:05');
|
|
||||||
assert.equal(settings.DEFAULT_SEND_TIME_NOON, '13:05');
|
|
||||||
assert.equal(settings.DEFAULT_SEND_TIME_AFTERNOON, '18:05');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('超长文案格式化:分钟 / 小时 / 跨天', () => {
|
|
||||||
assert.equal(job.formatOverdue(30), '30 分钟');
|
|
||||||
assert.equal(job.formatOverdue(120), '2 小时');
|
|
||||||
assert.equal(job.formatOverdue(388), '6 小时 28 分');
|
|
||||||
assert.equal(job.formatOverdue(1500), '1 天 1 小时');
|
|
||||||
assert.equal(job.formatOverdue(0), '—');
|
|
||||||
assert.equal(job.formatOverdue(null), '—');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('页面链接带上轮次参数,业务点进去看的就是这一轮', () => {
|
|
||||||
const link = job.buildPageLink('https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness', 'AFTERNOON');
|
|
||||||
assert.equal(link, 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness?round=AFTERNOON');
|
|
||||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
|
||||||
assert.match(built.text, /\?round=NOON/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('轮次元信息:13:00 轮核验 07:00、18:00 轮核验 16:00', () => {
|
|
||||||
const noon = job.resolveRoundMeta('NOON', bj(2026, 10, 27, 13, 5));
|
|
||||||
const afternoon = job.resolveRoundMeta('AFTERNOON', bj(2026, 10, 27, 18, 5));
|
|
||||||
assert.equal(noon.deadline, '2026-10-27 13:00:00');
|
|
||||||
assert.equal(noon.windowEnd, '2026-10-27 07:00:00');
|
|
||||||
assert.equal(afternoon.deadline, '2026-10-27 18:00:00');
|
|
||||||
assert.equal(afternoon.windowEnd, '2026-10-27 16:00:00');
|
|
||||||
assert.equal(job.resolveRoundMeta('UNKNOWN', bj(2026, 10, 27, 13, 5)), null);
|
|
||||||
});
|
|
||||||
@ -3,7 +3,7 @@ import request from './request';
|
|||||||
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
// 缺货补货监控(订单驱动版)
|
// 缺货补货监控(订单驱动版)
|
||||||
// 判断口径:待发订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
|
// 判断口径:当日订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
|
||||||
export const getBinReplenishList = (params = {}) => request.get(
|
export const getBinReplenishList = (params = {}) => request.get(
|
||||||
'/api/bin-replenish/list',
|
'/api/bin-replenish/list',
|
||||||
{ params, timeout: LONG_TIMEOUT_MS }
|
{ params, timeout: LONG_TIMEOUT_MS }
|
||||||
|
|||||||
@ -21,40 +21,3 @@ export const getWarehouseShipTimelinessRounds = () => request.get(
|
|||||||
'/api/warehouse-ship-timeliness/rounds',
|
'/api/warehouse-ship-timeliness/rounds',
|
||||||
{ timeout: 30000 }
|
{ timeout: 30000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 「超时未发」明细导出:不分页、不截断,返回本轮考核口径内的全部行。
|
|
||||||
// 与 /list 共用后端同一份缓存,所以页面刚刷过时这里是秒回;
|
|
||||||
// 冷启动(缓存已过期)仍要全量扫视图,超时同样放大到 3 分钟。
|
|
||||||
export const getWarehouseShipTimelinessExportRows = (params = {}) => request.get(
|
|
||||||
'/api/warehouse-ship-timeliness/export',
|
|
||||||
{
|
|
||||||
params,
|
|
||||||
timeout: TIMELINESS_TIMEOUT_MS,
|
|
||||||
headers: { 'Cache-Control': 'no-cache' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 两轮核验完成后推文本到钉钉群(2026-10-27 新增)────────────────────────
|
|
||||||
// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选)
|
|
||||||
// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview)
|
|
||||||
export const getShipTimelinessNotifySettings = () => request.get(
|
|
||||||
'/api/warehouse-ship-timeliness/notify/settings',
|
|
||||||
{ timeout: 30000, headers: { 'Cache-Control': 'no-cache' } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填
|
|
||||||
export const saveShipTimelinessNotifySettings = (payload = {}) => request.put(
|
|
||||||
'/api/warehouse-ship-timeliness/notify/settings',
|
|
||||||
payload,
|
|
||||||
{ timeout: 30000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// 立即推送 / 预览正文 / 测试通道
|
|
||||||
// dryRun=true 只组装消息不发送(返回正文全文)
|
|
||||||
// probe=true 只发一句通道测试,不查业务数据
|
|
||||||
// 冷启动要全量扫订单状态视图(生产实测数十秒到几分钟),超时给足
|
|
||||||
export const runShipTimelinessNotify = (payload = {}) => request.post(
|
|
||||||
'/api/warehouse-ship-timeliness/notify/run',
|
|
||||||
payload,
|
|
||||||
{ timeout: 10 * 60 * 1000 }
|
|
||||||
);
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Empty, Input, Segmented, Spin, Table, Tag, Tooltip,
|
Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
@ -19,12 +19,15 @@ const STATUS_TAGS = {
|
|||||||
short: { color: 'red', label: '存储不足' },
|
short: { color: 'red', label: '存储不足' },
|
||||||
need: { color: 'gold', label: '需补货' },
|
need: { color: 'gold', label: '需补货' },
|
||||||
enough: { color: 'green', label: '拣货位充足' },
|
enough: { color: 'green', label: '拣货位充足' },
|
||||||
no_order: { color: 'default', label: '无待发单' },
|
no_order: { color: 'default', label: '今日无单' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ label: '待补货', value: 'replenish' },
|
{ label: '待补货', value: 'replenish' },
|
||||||
{ label: '仅缺货', value: 'short' },
|
{ label: '存储不足', value: 'short' },
|
||||||
|
{ label: '拣货位充足', value: 'enough' },
|
||||||
|
{ label: '今日无单', value: 'no_order' },
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const BinReplenishMonitor = () => {
|
const BinReplenishMonitor = () => {
|
||||||
@ -38,7 +41,7 @@ const BinReplenishMonitor = () => {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [data, setData] = useState({
|
const [data, setData] = useState({
|
||||||
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderScope: '', updatedAt: '', cacheHit: false,
|
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderDate: '', updatedAt: '', cacheHit: false,
|
||||||
});
|
});
|
||||||
const keywordTimer = useRef(null);
|
const keywordTimer = useRef(null);
|
||||||
|
|
||||||
@ -63,7 +66,7 @@ const BinReplenishMonitor = () => {
|
|||||||
summary: payload.summary || {},
|
summary: payload.summary || {},
|
||||||
warehouse: payload.warehouse || null,
|
warehouse: payload.warehouse || null,
|
||||||
warehouses: payload.warehouses || [],
|
warehouses: payload.warehouses || [],
|
||||||
orderScope: payload.orderScope || '',
|
orderDate: payload.orderDate || '',
|
||||||
updatedAt: payload.updatedAt || '',
|
updatedAt: payload.updatedAt || '',
|
||||||
cacheHit: Boolean(payload.cacheHit),
|
cacheHit: Boolean(payload.cacheHit),
|
||||||
});
|
});
|
||||||
@ -96,47 +99,47 @@ const BinReplenishMonitor = () => {
|
|||||||
|
|
||||||
const metrics = useMemo(() => ([
|
const metrics = useMemo(() => ([
|
||||||
{
|
{
|
||||||
key: 'order',
|
key: 'replenish',
|
||||||
tone: 'blue',
|
tone: 'red',
|
||||||
label: '实时代发订单数',
|
label: '待补货 SKU',
|
||||||
value: summary.orderQtyTotal || 0,
|
value: needCount,
|
||||||
unit: '件',
|
unit: '个',
|
||||||
foot: `待补 ${num(summary.skuTotal)} 个 SKU`,
|
foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`,
|
||||||
tip: '待补 SKU 的待发订单件数合计(取聚水潭「打单拣货 → 发货中」每日快照,组合装已展开为子件;不含已发/异常/待审核)',
|
tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来',
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'pick',
|
|
||||||
tone: 'green',
|
|
||||||
label: '拣货库存数',
|
|
||||||
value: summary.pickTotal || 0,
|
|
||||||
unit: '件',
|
|
||||||
foot: '待补 SKU 拣货位库存',
|
|
||||||
tip: '待补 SKU 在 XA / XD / XE 拣货位上的库存合计(库存取自聚水潭「箱及仓位库存 · 简尚-崔桥工厂仓 主仓」)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'store',
|
|
||||||
tone: 'orange',
|
|
||||||
label: '存储库存数',
|
|
||||||
value: summary.storageTotal || 0,
|
|
||||||
unit: '件',
|
|
||||||
foot: '待补 SKU 存储位库存',
|
|
||||||
tip: '待补 SKU 在 XX / X9 / X5 / X4 存储位上的库存合计(同上来源,主仓)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'short',
|
key: 'short',
|
||||||
tone: 'red',
|
tone: 'grey',
|
||||||
label: '缺货数',
|
label: '其中存储不足',
|
||||||
value: summary.skuTotal || 0,
|
value: summary.short || 0,
|
||||||
unit: '个',
|
unit: '个',
|
||||||
foot: `其中存储不足 ${num(summary.short)} 个,缺口 ${num(summary.shortfallTotal)} 件`,
|
foot: `存储缺口 ${num(summary.shortfallTotal)} 件`,
|
||||||
tip: '需要从存储位补到拣货位的 SKU 数(待发订单量 > 拣货位库存);其中存储位也补不齐的标为「缺货」,需采购 / 紧急调拨',
|
tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exceed',
|
||||||
|
tone: 'orange',
|
||||||
|
label: '超出订单数合计',
|
||||||
|
value: summary.exceedTotal || 0,
|
||||||
|
unit: '件',
|
||||||
|
foot: '今日拣货位缺口订单量',
|
||||||
|
tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'order',
|
||||||
|
tone: 'blue',
|
||||||
|
label: '当日有单 SKU',
|
||||||
|
value: summary.orderSkuCount || 0,
|
||||||
|
unit: '个',
|
||||||
|
foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`,
|
||||||
|
tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)',
|
||||||
},
|
},
|
||||||
]), [summary, needCount]);
|
]), [summary, needCount]);
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await exportBinReplenish({ warehouse, status, keyword });
|
const response = await exportBinReplenish({ warehouse, status, keyword });
|
||||||
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_待发订单.xlsx`);
|
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_${data.orderDate || ''}.xlsx`);
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
const text = await readBlobErrorMessage(error_);
|
const text = await readBlobErrorMessage(error_);
|
||||||
message.error(text || '导出失败');
|
message.error(text || '导出失败');
|
||||||
@ -185,7 +188,7 @@ const BinReplenishMonitor = () => {
|
|||||||
render: (value) => <span className="brp-qty">{num(value)}</span>,
|
render: (value) => <span className="brp-qty">{num(value)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '待发订单',
|
title: '当日订单',
|
||||||
dataIndex: 'orderQty',
|
dataIndex: 'orderQty',
|
||||||
width: 100,
|
width: 100,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
@ -235,9 +238,8 @@ const BinReplenishMonitor = () => {
|
|||||||
<div className="brp-hero__main">
|
<div className="brp-hero__main">
|
||||||
<h1 className="brp-hero__title">缺货补货监控</h1>
|
<h1 className="brp-hero__title">缺货补货监控</h1>
|
||||||
<p className="brp-hero__desc">
|
<p className="brp-hero__desc">
|
||||||
本看板只盯<b>崔桥工厂仓(主仓)</b>的<b>当日待补</b>数据:也就是那些待发订单超过拣货位库存、
|
盯的是<b>拣货位</b>:今天下的订单,如果拣货位库存不够拣,超出的那部分就要从<b>存储位</b>补到拣货位。
|
||||||
需要把货从<b>存储位</b>补到<b>拣货位</b>的 SKU。订单取自聚水潭「打单拣货 → 简尚-崔桥工厂仓」的发货中单据,
|
库存取自聚水潭「箱及仓位库存」,订单取自崔桥工厂仓当日订单。
|
||||||
库存取自聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」。<b>已发货的不再计入</b>(货已发出、不再消耗拣货位)。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="brp-hero__meta">
|
<div className="brp-hero__meta">
|
||||||
@ -245,14 +247,15 @@ const BinReplenishMonitor = () => {
|
|||||||
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
|
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
统计口径:<b>{data.orderScope || '待发订单 = 聚水潭「打单拣货」发货中快照(不含已发/异常/待审核)'}</b>
|
订单日期:<b>{data.orderDate || '--'}</b>
|
||||||
|
<span className="brp-muted">(取该仓最近一个有订单的营业日)</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
|
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
|
||||||
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
|
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
共 <b>{num(summary.skuTotal)}</b> 个待补 SKU,其中存储不足(缺货)<b>{num(summary.short)}</b> 个
|
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中今日无单 <b>{num(summary.noOrder)}</b> 个
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -278,7 +281,15 @@ const BinReplenishMonitor = () => {
|
|||||||
<div className="brp-toolbar">
|
<div className="brp-toolbar">
|
||||||
<span className="brp-toolbar__field">
|
<span className="brp-toolbar__field">
|
||||||
<span className="brp-toolbar__label">仓库</span>
|
<span className="brp-toolbar__label">仓库</span>
|
||||||
<span className="brp-toolbar__static">简尚-崔桥工厂仓(主仓)</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>
|
||||||
<span className="brp-toolbar__field">
|
<span className="brp-toolbar__field">
|
||||||
<span className="brp-toolbar__label">范围</span>
|
<span className="brp-toolbar__label">范围</span>
|
||||||
@ -289,7 +300,11 @@ const BinReplenishMonitor = () => {
|
|||||||
...item,
|
...item,
|
||||||
label: item.value === 'replenish'
|
label: item.value === 'replenish'
|
||||||
? `${item.label} ${num(needCount)}`
|
? `${item.label} ${num(needCount)}`
|
||||||
: `${item.label} ${num(summary.short || 0)}`,
|
: item.value === 'short'
|
||||||
|
? `${item.label} ${num(summary.short || 0)}`
|
||||||
|
: item.value === 'no_order'
|
||||||
|
? `${item.label} ${num(summary.noOrder || 0)}`
|
||||||
|
: item.label,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@ -317,12 +332,11 @@ const BinReplenishMonitor = () => {
|
|||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
className="brp-alert"
|
className="brp-alert"
|
||||||
message="货位口径(崔桥工厂仓 主仓约定)"
|
message="货位口径(崔桥工厂仓约定)"
|
||||||
description={(
|
description={(
|
||||||
<span>
|
<span>
|
||||||
库存只取 <b>崔桥工厂仓主仓(wh_id=1)</b>;拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;
|
拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。
|
||||||
其余货位(DB / Y* / Z* 等,占比极小)不计入。
|
判断规则:<b>当日订单量 > 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
|
||||||
判断规则:<b>待发订单量 > 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -339,7 +353,7 @@ const BinReplenishMonitor = () => {
|
|||||||
|
|
||||||
<Spin
|
<Spin
|
||||||
spinning={loading}
|
spinning={loading}
|
||||||
tip="正在聚合仓位库存 + 待发订单,首次约 30~90 秒"
|
tip="正在聚合仓位库存 + 当日订单,首次约 30~90 秒"
|
||||||
wrapperClassName="brp-table-spin"
|
wrapperClassName="brp-table-spin"
|
||||||
>
|
>
|
||||||
<Table
|
<Table
|
||||||
@ -377,30 +391,24 @@ const BinReplenishMonitor = () => {
|
|||||||
<div className="brp-notes__title">数据来源与口径说明</div>
|
<div className="brp-notes__title">数据来源与口径说明</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<b>库存</b>:聚水潭「箱及仓位库存 → 简尚-崔桥工厂仓 主仓」(rpt.jst_packInventoryInfo,仅 <b>wh_id=1 主仓</b>),
|
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码(sku_id)聚合。
|
||||||
按商品编码(sku_id)聚合。「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;
|
「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
|
||||||
「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>订单</b>:直接取聚水潭「<b>打单拣货 → 简尚-崔桥工厂仓</b>」的发货中单据快照 = rpt.op_OrderNotDispatched
|
<b>订单</b>:崔桥工厂仓当日订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B),
|
||||||
(该仓<b>最新快照日</b>、status=发货中),SKU 级、组合装已展开为子件。
|
均按 send_date 落在「订单日期」当天;组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
|
||||||
<b>已发货 / 异常挂起 / 已付款待审核 / 待付款均不计入</b>——货已发出或短期不会拣,不该驱动补货。
|
|
||||||
口径仅覆盖<b>聚水潭线上店铺渠道</b>(运营中心 / 分销等),<b>不含全民(qmcust) B2B 客户单</b>。
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>看板范围</b>:本看板<b>只显示当日待补</b> SKU —— 即「待发订单量 > 拣货位库存」、需要从存储位补到拣货位的这部分
|
<b>判断规则</b>:对每个 SKU,若 <b>当日订单量 > 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
|
||||||
(含拣货位为 0、库存视图里查不到但有打单拣货待发单的 SKU)。其余「拣货位充足 / 无待发单」的 SKU 不进本看板。
|
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>判断规则</b>:对每个待补 SKU,<b>超出订单数</b> = max(0, 待发订单量 − 拣货位库存);
|
<b>订单日期</b>:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空;
|
||||||
「建议补货量」= min(超出订单数, 存储位库存);存储位也不够时标记「缺货」,缺口需采购 / 紧急调拨。
|
页面顶部明确标注统计的是哪一天。
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<b>统计范围</b>:取该仓<b>最新一天的「打单拣货」快照</b>(ETL 约每日 07:00 刷新);已发/异常/待审核一律排除。
|
<b>时效</b>:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
|
||||||
</li>
|
点「刷新数据」会强制回源,需要等 1 分钟左右。
|
||||||
<li>
|
|
||||||
<b>时效</b>:库存视图聚合约 1~2s,待发快照两步查询约 12s;服务端缓存 10 分钟(启动时已预热);
|
|
||||||
点「刷新数据」会强制回源,需要等约 15~30 秒。
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -779,16 +779,14 @@ function WarehouseLogisticsException() {
|
|||||||
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
|
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
|
||||||
: null}
|
: null}
|
||||||
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
||||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流;
|
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。
|
||||||
<b>现场拦截</b>(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常;
|
|
||||||
<b>转仓代发</b>(标签如「转x00005」,货由目标仓发出)—— 不算本仓异常,也不算本仓发货量。
|
|
||||||
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
||||||
|| exclusions.nonRealOrders > 0 || exclusions.transferOrders > 0) ? (
|
|| exclusions.nonRealOrders > 0) ? (
|
||||||
<>
|
<>
|
||||||
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
||||||
+ (exclusions.nonRealOrders || 0) + (exclusions.transferOrders || 0)}</b> 单
|
+ (exclusions.nonRealOrders || 0)}</b> 单
|
||||||
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
||||||
刷单/信誉单 {exclusions.nonRealOrders || 0} 单、转仓代发 {exclusions.transferOrders || 0} 单),
|
刷单/信誉单 {exclusions.nonRealOrders || 0} 单),
|
||||||
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
|
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
|
||||||
<>
|
<>
|
||||||
其中承运商剔除:<b>
|
其中承运商剔除:<b>
|
||||||
@ -802,20 +800,6 @@ function WarehouseLogisticsException() {
|
|||||||
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
|
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{exclusions?.interceptOrders > 0 ? (
|
|
||||||
<>
|
|
||||||
{' '}另有 <b>{exclusions.interceptOrders}</b> 单属<b>现场拦截(售后已签收)</b>
|
|
||||||
{exclusions.interceptReasons && Object.keys(exclusions.interceptReasons).length ? (
|
|
||||||
<>(原本会算「
|
|
||||||
{Object.entries(exclusions.interceptReasons)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([k, v]) => `${k} ${v} 单`)
|
|
||||||
.join(' / ')}
|
|
||||||
」)</>
|
|
||||||
) : null}
|
|
||||||
—— 货是被快递当场拦下原车拉回、由售后仓签收的,<b>仍计入发货分母、只是不再报异常</b>。
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<br />
|
<br />
|
||||||
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
|
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
|
||||||
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
|
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
|
||||||
|
|||||||
@ -1,19 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Collapse, Dropdown, Empty, Input, InputNumber, Modal, Space, Spin,
|
Alert, Button, Card, Empty, Modal, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
|
||||||
Statistic, Switch, Table, Tag, TimePicker, Tooltip, Typography, message,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { BellOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { SycmPage } from '../../components/Common';
|
import { SycmPage } from '../../components/Common';
|
||||||
import {
|
import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness';
|
||||||
getShipTimelinessNotifySettings,
|
|
||||||
getWarehouseShipTimeliness,
|
|
||||||
getWarehouseShipTimelinessExportRows,
|
|
||||||
runShipTimelinessNotify,
|
|
||||||
saveShipTimelinessNotifySettings,
|
|
||||||
} from '../../api/warehouseShipTimeliness';
|
|
||||||
import { exportTableFile } from '../../utils/tableExport';
|
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
@ -22,88 +13,6 @@ const { Title, Text } = Typography;
|
|||||||
// 接口要扫两张订单表 + 出库表(rpt 下全是视图),冷启动 40~60 秒,别跟着其它页面的轮询节奏跑。
|
// 接口要扫两张订单表 + 出库表(rpt 下全是视图),冷启动 40~60 秒,别跟着其它页面的轮询节奏跑。
|
||||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
// 「钉钉通知」表单初始值。两个轮次各一个推送时刻(默认核验点 + 5 分钟)。
|
|
||||||
// 加签密钥永远不回显明文:留空 = 沿用服务端已存的那份
|
|
||||||
const EMPTY_NOTIFY_FORM = {
|
|
||||||
enabled: false,
|
|
||||||
noonSendTime: '13:05',
|
|
||||||
afternoonSendTime: '18:05',
|
|
||||||
webhookUrl: '',
|
|
||||||
webhookSecret: '',
|
|
||||||
maxDetailRows: 30,
|
|
||||||
atAll: false,
|
|
||||||
pageUrl: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 后端「没推」的原因 → 人话(后端 reason 是给日志用的英文标识)
|
|
||||||
const NOTIFY_REASON_TEXT = {
|
|
||||||
disabled: '推送开关没打开',
|
|
||||||
not_configured: 'Webhook 地址还没填',
|
|
||||||
already_sent: '这一轮今天已经推过了(同一天同一轮只推一次)',
|
|
||||||
not_started: '这一轮还没到核验时刻,没有结论可推',
|
|
||||||
before_deadline: '还没到本轮核验时刻,推到群里会是半成品',
|
|
||||||
dry_run: '仅预览,未发送',
|
|
||||||
error: '执行出错',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 钉钉群机器人 webhook 怎么拿 —— 业务自己配,写清楚省得来问
|
|
||||||
const NOTIFY_HELP_ITEMS = [
|
|
||||||
{
|
|
||||||
key: 'webhook',
|
|
||||||
label: '① 拿群机器人 Webhook 地址',
|
|
||||||
children: (
|
|
||||||
<div className="wst-notify__help">
|
|
||||||
电脑版钉钉 → 打开目标群 → 右上角 <b>群设置</b> → <b>机器人</b> → <b>添加机器人</b> →
|
|
||||||
选 <b>自定义</b> → 起个名字(如「发货时效通知」)→ 复制 <b>Webhook 地址</b>。
|
|
||||||
<br />
|
|
||||||
地址形如 <code>https://oapi.dingtalk.com/robot/send?access_token=xxxx</code>,
|
|
||||||
整串(含 access_token)直接贴到下面。
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'security',
|
|
||||||
label: '② 安全设置:加签密钥(可选)',
|
|
||||||
children: (
|
|
||||||
<div className="wst-notify__help">
|
|
||||||
添加机器人时如果安全设置选了 <b>加签</b>,把以 <code>SEC</code> 开头的密钥填到「加签密钥」里。
|
|
||||||
<br />
|
|
||||||
如果选的是 <b>自定义关键词</b>,密钥留空即可 —— 但关键词必须包含 <b>超时</b>
|
|
||||||
(本模块消息抬头固定是「【仓储中心发货时效】」,正文里有「超时未发货 N 单」,天然命中)。
|
|
||||||
<br />
|
|
||||||
如果选的是 <b>IP 地址(段)</b>,需要把生产服务器 IP 加进白名单,密钥同样留空。
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'round',
|
|
||||||
label: '③ 为什么一天推两次、时间怎么定',
|
|
||||||
children: (
|
|
||||||
<div className="wst-notify__help">
|
|
||||||
业务口径:<b>13:00 核验 7 点前审核的超时单</b>、<b>18:00 核验 16 点前审核的单</b>,
|
|
||||||
各自<b>核验完成后</b>推一次,所以是两个独立时刻,互不影响。
|
|
||||||
<br />
|
|
||||||
默认给到 <b>13:05 / 18:05</b>(核验点 + 5 分钟):聚水潭状态镜像要晚几分钟才覆盖到核验时刻,
|
|
||||||
整点推容易报「数据没覆盖核验点、超时数偏大」。想改成整点也可以,消息里会带这句提醒。
|
|
||||||
<br />
|
|
||||||
同一轮次同一天只推一次(服务重启不会重复轰炸);要重发点「立即推送」。
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'why',
|
|
||||||
label: '④ 为什么群里只有文字,没有 Excel 附件',
|
|
||||||
children: (
|
|
||||||
<div className="wst-notify__help">
|
|
||||||
钉钉的<b>自定义机器人 webhook</b> 只支持 text / markdown / link / actionCard,
|
|
||||||
<b>不支持发文件</b>。所以这里推的是<b>文本消息</b>:汇总数字 + 前 N 条明细 + 页面链接。
|
|
||||||
<br />
|
|
||||||
要全量明细或 Excel,点消息末尾的链接进页面查看 / 导出(口径与消息完全一致)。
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const number = (value, digits = 0) => (
|
const number = (value, digits = 0) => (
|
||||||
value === null || value === undefined || value === ''
|
value === null || value === undefined || value === ''
|
||||||
? '0'
|
? '0'
|
||||||
@ -274,51 +183,6 @@ const DETAIL_COLUMNS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出列(「超时未发」明细)。
|
|
||||||
*
|
|
||||||
* ⚠️ 必须**单独一份**,不能直接复用 DETAIL_COLUMNS:
|
|
||||||
* ① 那里的 title 是 JSX(带 Tooltip),导出时会变成 "[object Object]" 当表头;
|
|
||||||
* ② 页面上没有「快递公司 / 运单号」两列,而业务追单恰恰要这两列
|
|
||||||
* (有运单号 = 已打单未揽收,能一眼看出卡在哪一步)。
|
|
||||||
* 标题一律纯字符串,格式化统一走 exportValue(null 值别导出裸空/裸 0)。
|
|
||||||
*/
|
|
||||||
const EXPORT_COLUMNS = [
|
|
||||||
{ title: '内部单号', dataIndex: 'oId' },
|
|
||||||
{ title: '线上订单号', dataIndex: 'soId' },
|
|
||||||
{ title: '店铺', dataIndex: 'shopName' },
|
|
||||||
{ title: '仓储中心', dataIndex: 'centerName' },
|
|
||||||
{ title: '发货仓', dataIndex: 'warehouseName' },
|
|
||||||
{ title: '快递公司', dataIndex: 'logisticCompany', exportValue: (v) => v || '未指定' },
|
|
||||||
{ title: '运单号', dataIndex: 'logisticId', exportValue: (v) => v || '未打单' },
|
|
||||||
{ title: '物流状态', dataIndex: 'logisticStatus' },
|
|
||||||
{ title: '发货状态', dataIndex: 'state' },
|
|
||||||
{ title: '发货时间', dataIndex: 'sendTime', exportValue: (v) => v || '未发货' },
|
|
||||||
{
|
|
||||||
title: '审核时间',
|
|
||||||
dataIndex: 'auditTime',
|
|
||||||
// 审核时间缺失时页面会回退创建/付款并打 `*`,导出也保持一致,否则对账对不上
|
|
||||||
exportValue: (v, row) => v || (row?.benchmarkTime ? `${row.benchmarkTime} *` : '未审核'),
|
|
||||||
},
|
|
||||||
{ title: '付款时间', dataIndex: 'payTime' },
|
|
||||||
// 分钟数给一列原始值,业务在 Excel 里能直接排序/筛选;人读的那列再格式化
|
|
||||||
{ title: '已等(分钟)', dataIndex: 'overdueMinutes' },
|
|
||||||
{ title: '已等时长', dataIndex: 'overdueMinutes', exportValue: (v) => formatOverdue(v) },
|
|
||||||
];
|
|
||||||
|
|
||||||
// key = 导出格式(tableExport 只支持这两种)
|
|
||||||
const EXPORT_MENU_ITEMS = [
|
|
||||||
{ key: 'xlsx', label: '导出 Excel(.xlsx)' },
|
|
||||||
{ key: 'csv', label: '导出 CSV(.csv)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 文件名里不能出现 : \ / * ? " < > |,轮次用「13点轮」这种写法避开冒号
|
|
||||||
const roundFileText = (roundInfo) => (
|
|
||||||
Number.isFinite(Number(roundInfo?.verifyHour))
|
|
||||||
? `${String(Number(roundInfo.verifyHour)).padStart(2, '0')}点轮`
|
|
||||||
: '本轮'
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储中心 24 小时发货时效(独立页面)
|
* 仓储中心 24 小时发货时效(独立页面)
|
||||||
* ────────────────────────────────────────────────────────────────
|
* ────────────────────────────────────────────────────────────────
|
||||||
@ -344,18 +208,6 @@ function WarehouseShipTimeliness() {
|
|||||||
const [activeCenter, setActiveCenter] = useState('');
|
const [activeCenter, setActiveCenter] = useState('');
|
||||||
// 「业务挂起(疑问单)」明细弹窗
|
// 「业务挂起(疑问单)」明细弹窗
|
||||||
const [heldOpen, setHeldOpen] = useState(false);
|
const [heldOpen, setHeldOpen] = useState(false);
|
||||||
// 「导出明细」按钮 loading(全量导出可能要走一次冷启动查询,必须给反馈)
|
|
||||||
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);
|
|
||||||
|
|
||||||
// force=true 时带 force=1 让后端绕过 5 分钟结果缓存重查库;
|
// force=true 时带 force=1 让后端绕过 5 分钟结果缓存重查库;
|
||||||
// 定时轮询走缓存(这张视图单次要几十秒,每轮都重查会把库压死)
|
// 定时轮询走缓存(这张视图单次要几十秒,每轮都重查会把库压死)
|
||||||
@ -412,210 +264,10 @@ function WarehouseShipTimeliness() {
|
|||||||
return hit ? hit.name : activeCenter;
|
return hit ? hit.name : activeCenter;
|
||||||
}, [activeCenter, centers]);
|
}, [activeCenter, centers]);
|
||||||
|
|
||||||
// 选中仓时,该仓的超时单数以 centers 里的统计为准(明细可能被截断)
|
|
||||||
const activeCenterRow = useMemo(
|
|
||||||
() => (activeCenter ? centers.find((c) => c.key === activeCenter) || null : null),
|
|
||||||
[activeCenter, centers]
|
|
||||||
);
|
|
||||||
|
|
||||||
const detailOrders = useMemo(() => (
|
const detailOrders = useMemo(() => (
|
||||||
activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders
|
activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders
|
||||||
), [activeCenter, orders]);
|
), [activeCenter, orders]);
|
||||||
|
|
||||||
// 明细真实总数(后端截断前):页面的「共 N 单」必须用它,
|
|
||||||
// 用 detailOrders.length 会把「已截断只显示前 1000 条」写成「一共就这么点」
|
|
||||||
const detailTotal = Number.isFinite(Number(data?.detailTotal))
|
|
||||||
? Number(data.detailTotal)
|
|
||||||
: detailOrders.length;
|
|
||||||
|
|
||||||
const heldTotal = Number.isFinite(Number(data?.heldTotal))
|
|
||||||
? Number(data.heldTotal)
|
|
||||||
: heldOrders.length;
|
|
||||||
|
|
||||||
// 不考核快递清单跟随后端(加/减快递只改服务层一处,别在前端写死)
|
|
||||||
const excludedCarrierText = (
|
|
||||||
Array.isArray(data?.excludedCarriers) && data.excludedCarriers.length
|
|
||||||
? data.excludedCarriers
|
|
||||||
: ['自提', '中铁', '壹米滴答', '武进安能']
|
|
||||||
).join(' / ');
|
|
||||||
|
|
||||||
// ── 导出「超时未发」明细 ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出口径必须和页面明细一致,所以:
|
|
||||||
* · 数据源走 `/export`(后端同一个 getShipTimeliness,只是不按 limit 截断);
|
|
||||||
* · 选中仓时在前端按 centerKey 过滤,和后端算超时用的主仓归属是同一套。
|
|
||||||
* 🔥 不能直接导出页面上的 `orders` —— 它被 limit=1000 截断过,导出会少行,
|
|
||||||
* 而业务拿它去考核,少行比报错更危险。
|
|
||||||
*/
|
|
||||||
const handleExport = async (format) => {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const result = await getWarehouseShipTimelinessExportRows({
|
|
||||||
round: round?.key || undefined,
|
|
||||||
});
|
|
||||||
if (!result || result.success === false) {
|
|
||||||
message.error(result?.error || '导出失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = result.data || {};
|
|
||||||
if (payload.notStarted) {
|
|
||||||
message.warning('本轮还没到核验时间,没有可导出的数据');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const allRows = Array.isArray(payload.rows) ? payload.rows : [];
|
|
||||||
if (!allRows.length) {
|
|
||||||
message.warning('本轮没有超时未发订单,无需导出');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rows = activeCenter
|
|
||||||
? allRows.filter((row) => row.centerKey === activeCenter)
|
|
||||||
: allRows;
|
|
||||||
if (!rows.length) {
|
|
||||||
message.warning(`${activeCenterName || '该仓'}本轮没有超时未发订单`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fileName = [
|
|
||||||
'仓储中心发货时效_超时未发',
|
|
||||||
activeCenter ? activeCenterName : '全部仓库',
|
|
||||||
roundFileText(payload.round || round),
|
|
||||||
payload.date || data?.date || dayjs().format('YYYY-MM-DD'),
|
|
||||||
].filter(Boolean).join('_');
|
|
||||||
await exportTableFile(format, fileName, EXPORT_COLUMNS, rows, '超时未发');
|
|
||||||
message.success(`已导出 ${rows.length} 单(${String(format).toUpperCase()})`);
|
|
||||||
} 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,
|
|
||||||
noonSendTime: config?.noonSendTime || '13:05',
|
|
||||||
afternoonSendTime: config?.afternoonSendTime || '18:05',
|
|
||||||
webhookUrl: config?.webhookUrl || '',
|
|
||||||
webhookSecret: '',
|
|
||||||
maxDetailRows: Number.isFinite(Number(config?.maxDetailRows))
|
|
||||||
? Number(config.maxDetailRows)
|
|
||||||
: 30,
|
|
||||||
atAll: config?.atAll === true,
|
|
||||||
pageUrl: config?.pageUrl || '',
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openNotifySettings = useCallback(async () => {
|
|
||||||
setNotifyOpen(true);
|
|
||||||
setNotifyPreview(null);
|
|
||||||
setNotifyLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await getShipTimelinessNotifySettings();
|
|
||||||
if (!result || result.success === false) {
|
|
||||||
message.error(result?.error || '读取发货时效通知配置失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyNotifyConfig(result.data);
|
|
||||||
} catch (loadError) {
|
|
||||||
message.error(loadError?.response?.data?.error || loadError?.message || '读取发货时效通知配置失败');
|
|
||||||
} finally {
|
|
||||||
setNotifyLoading(false);
|
|
||||||
}
|
|
||||||
}, [applyNotifyConfig]);
|
|
||||||
|
|
||||||
const buildNotifyPayload = useCallback(() => ({
|
|
||||||
enabled: notifyForm.enabled,
|
|
||||||
noonSendTime: notifyForm.noonSendTime,
|
|
||||||
afternoonSendTime: notifyForm.afternoonSendTime,
|
|
||||||
webhookUrl: String(notifyForm.webhookUrl || '').trim(),
|
|
||||||
webhookSecret: String(notifyForm.webhookSecret || '').trim(),
|
|
||||||
maxDetailRows: Number(notifyForm.maxDetailRows) || 0,
|
|
||||||
atAll: notifyForm.atAll === true,
|
|
||||||
pageUrl: String(notifyForm.pageUrl || '').trim(),
|
|
||||||
}), [notifyForm]);
|
|
||||||
|
|
||||||
const handleSaveNotify = async ({ silent = false } = {}) => {
|
|
||||||
setNotifySaving(true);
|
|
||||||
try {
|
|
||||||
const result = await saveShipTimelinessNotifySettings(buildNotifyPayload());
|
|
||||||
if (!result || result.success === false) {
|
|
||||||
message.error(result?.error || '保存发货时效通知配置失败');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
applyNotifyConfig(result.data);
|
|
||||||
if (!silent) message.success('发货时效通知配置已保存');
|
|
||||||
return result.data;
|
|
||||||
} catch (saveError) {
|
|
||||||
message.error(saveError?.response?.data?.error || saveError?.message || '保存发货时效通知配置失败');
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setNotifySaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* mode: 'dry' 预览正文(不发送)| 'probe' 只发一句通道测试 | 'send' 立即推送当前轮
|
|
||||||
* 都要先保存:否则「改完 webhook 直接点测试」会拿旧地址发,排错时会很迷惑。
|
|
||||||
*
|
|
||||||
* ⚠️ 预览**不能**用 `Modal.info` 静态方法:本项目跑 React 19,antd v5 的静态方法
|
|
||||||
* 拿不到 ConfigProvider context(控制台只给一行 warning),弹窗**静默不渲染**。
|
|
||||||
* 所以预览改成弹窗内的受控区块(notifyPreview)。
|
|
||||||
*/
|
|
||||||
const handleRunNotify = async (mode) => {
|
|
||||||
const saved = await handleSaveNotify({ silent: true });
|
|
||||||
if (!saved) return;
|
|
||||||
setNotifyRunning(true);
|
|
||||||
try {
|
|
||||||
const payload = mode === 'dry'
|
|
||||||
? { dryRun: true, force: true }
|
|
||||||
: { probe: mode === 'probe', force: true };
|
|
||||||
const result = await runShipTimelinessNotify(payload);
|
|
||||||
if (!result || result.success === false) {
|
|
||||||
message.error(result?.error || '推送到钉钉群失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const info = result.data || {};
|
|
||||||
if (mode === 'dry') {
|
|
||||||
setNotifyPreview({
|
|
||||||
text: info.text || '',
|
|
||||||
rowCount: info.rowCount ?? 0,
|
|
||||||
listedCount: info.listedCount ?? 0,
|
|
||||||
bytes: info.bytes || 0,
|
|
||||||
truncated: info.truncated === true,
|
|
||||||
roundLabel: info.roundLabel || '',
|
|
||||||
});
|
|
||||||
message.success('已生成预览(未发送),正文见下方');
|
|
||||||
} else if (mode === 'probe') {
|
|
||||||
message.success('通道测试已发出,去钉钉群看有没有收到那条测试消息');
|
|
||||||
} else if (info.sent) {
|
|
||||||
setNotifyPreview(null);
|
|
||||||
message.success(`已推送到钉钉群(${info.roundLabel || info.round},超时 ${info.rowCount ?? 0} 单,列出 ${info.listedCount ?? 0} 条)`);
|
|
||||||
} else {
|
|
||||||
message.warning(`未推送:${NOTIFY_REASON_TEXT[info.reason] || info.reason || '未知原因'}`);
|
|
||||||
}
|
|
||||||
const latest = await getShipTimelinessNotifySettings();
|
|
||||||
if (latest?.data) applyNotifyConfig(latest.data);
|
|
||||||
} catch (runError) {
|
|
||||||
message.error(runError?.response?.data?.error || runError?.message || '推送到钉钉群失败');
|
|
||||||
} finally {
|
|
||||||
setNotifyRunning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const notifyLastState = notifyConfig?.lastState || {};
|
|
||||||
const notifyNoonValue = useMemo(
|
|
||||||
() => (notifyForm.noonSendTime ? dayjs(notifyForm.noonSendTime, 'HH:mm') : null),
|
|
||||||
[notifyForm.noonSendTime]
|
|
||||||
);
|
|
||||||
const notifyAfternoonValue = useMemo(
|
|
||||||
() => (notifyForm.afternoonSendTime ? dayjs(notifyForm.afternoonSendTime, 'HH:mm') : null),
|
|
||||||
[notifyForm.afternoonSendTime]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SycmPage className="wst-page">
|
<SycmPage className="wst-page">
|
||||||
<header className="wst-header">
|
<header className="wst-header">
|
||||||
@ -637,7 +289,7 @@ function WarehouseShipTimeliness() {
|
|||||||
—— 该字段经常不回写,会把早就发货的单误判成超时。
|
—— 该字段经常不回写,会把早就发货的单误判成超时。
|
||||||
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
||||||
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
||||||
<b>{excludedCarrierText} 不考核</b>(客户自提、专线大件或加盟快运网点,揽收与在途仓库控制不了),
|
<b>自提 / 中铁 / 壹米滴答 三个快递不考核</b>(客户自提或专线大件,仓库控制不了运输),
|
||||||
已按物流公司自动剔除。
|
已按物流公司自动剔除。
|
||||||
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
||||||
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
|
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
|
||||||
@ -675,7 +327,6 @@ function WarehouseShipTimeliness() {
|
|||||||
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s,点刷新重查)` : ''}
|
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s,点刷新重查)` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Button icon={<BellOutlined />} onClick={openNotifySettings}>钉钉通知</Button>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
|
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@ -815,33 +466,16 @@ function WarehouseShipTimeliness() {
|
|||||||
<span>
|
<span>
|
||||||
超时未发货订单
|
超时未发货订单
|
||||||
<span className="wst-detail__scope">
|
<span className="wst-detail__scope">
|
||||||
{activeCenterName || '全部仓库'} · {activeCenter
|
{activeCenterName || '全部仓库'} · {detailOrders.length} 单
|
||||||
? (activeCenterRow?.overdueUnshipped ?? detailOrders.length)
|
{data?.truncated && detailOrders.length >= (data?.detailLimit || 0)
|
||||||
: detailTotal} 单
|
? `(已截断,仅显示前 ${data.detailLimit} 条)`
|
||||||
{!activeCenter && data?.truncated
|
|
||||||
? `(页面只显示前 ${data.detailLimit} 条,导出可拿全部 ${detailTotal} 条)`
|
|
||||||
: ''}
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
extra={(
|
extra={activeCenter ? (
|
||||||
<Space size={8}>
|
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>查看全部</button>
|
||||||
{activeCenter ? (
|
) : null}
|
||||||
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>
|
|
||||||
查看全部
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<Dropdown
|
|
||||||
trigger={['click']}
|
|
||||||
disabled={exporting || notStarted}
|
|
||||||
menu={{ items: EXPORT_MENU_ITEMS, onClick: ({ key }) => handleExport(key) }}
|
|
||||||
>
|
|
||||||
<Button size="small" icon={<DownloadOutlined />} loading={exporting}>
|
|
||||||
导出明细
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Spin spinning={loading && !data}>
|
<Spin spinning={loading && !data}>
|
||||||
{notStarted ? (
|
{notStarted ? (
|
||||||
@ -866,12 +500,12 @@ function WarehouseShipTimeliness() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`不计考核(待审核 / 异常)· ${heldTotal} 单`}
|
title={`不计考核(待审核 / 异常)· ${heldOrders.length} 单`}
|
||||||
open={heldOpen}
|
open={heldOpen}
|
||||||
onCancel={() => setHeldOpen(false)}
|
onCancel={() => setHeldOpen(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={1080}
|
width={1080}
|
||||||
destroyOnHidden
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
@ -893,183 +527,6 @@ function WarehouseShipTimeliness() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="发货时效通知 · 两轮核验完成后各推一次到钉钉群"
|
|
||||||
open={notifyOpen}
|
|
||||||
onCancel={() => setNotifyOpen(false)}
|
|
||||||
width={760}
|
|
||||||
destroyOnHidden
|
|
||||||
maskClosable={false}
|
|
||||||
styles={{ body: { maxHeight: '62vh', overflowY: 'auto' } }}
|
|
||||||
footer={[
|
|
||||||
<Button key="close" onClick={() => setNotifyOpen(false)}>关闭</Button>,
|
|
||||||
<Button key="probe" onClick={() => handleRunNotify('probe')} loading={notifyRunning} disabled={notifySaving}>
|
|
||||||
测试通道
|
|
||||||
</Button>,
|
|
||||||
<Button key="preview" onClick={() => handleRunNotify('dry')} loading={notifyRunning} disabled={notifySaving}>
|
|
||||||
预览正文
|
|
||||||
</Button>,
|
|
||||||
<Button key="save" onClick={() => handleSaveNotify()} loading={notifySaving}>保存</Button>,
|
|
||||||
<Button key="run" type="primary" onClick={() => handleRunNotify('send')} loading={notifyRunning} disabled={notifySaving}>
|
|
||||||
立即推送到群
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Spin spinning={notifyLoading}>
|
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
||||||
<Alert
|
|
||||||
type={notifyConfig?.ready ? 'success' : 'warning'}
|
|
||||||
showIcon
|
|
||||||
message={notifyConfig?.ready
|
|
||||||
? `通道已配好:13:00 轮 → ${notifyForm.noonSendTime} 推、18:00 轮 → ${notifyForm.afternoonSendTime} 推(0 单超时也推,用来确认任务在跑)`
|
|
||||||
: 'Webhook 地址还没配,填好保存后点「测试通道」验一下'}
|
|
||||||
description={notifyConfig?.ready ? null : (
|
|
||||||
<span>
|
|
||||||
缺:<b>{(notifyConfig?.missingFields || []).join(' / ') || '(尚未读取)'}</b>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
|
||||||
<span className="wst-notify__label">开启推送</span>
|
|
||||||
<Switch
|
|
||||||
checked={notifyForm.enabled}
|
|
||||||
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
|
|
||||||
checkedChildren="开"
|
|
||||||
unCheckedChildren="关"
|
|
||||||
/>
|
|
||||||
<Text type="secondary">关掉后定时任务不推,但「立即推送」仍可用(方便先试口径)</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
|
||||||
<span className="wst-notify__label">13:00 轮</span>
|
|
||||||
<TimePicker
|
|
||||||
format="HH:mm"
|
|
||||||
value={notifyNoonValue}
|
|
||||||
allowClear={false}
|
|
||||||
minuteStep={5}
|
|
||||||
style={{ width: 96 }}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (!value) return;
|
|
||||||
setNotifyForm((old) => ({ ...old, noonSendTime: value.format('HH:mm') }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text type="secondary">核验「今天 07:00 前审核」的超时单,默认核验点 + 5 分钟</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
|
||||||
<span className="wst-notify__label">18:00 轮</span>
|
|
||||||
<TimePicker
|
|
||||||
format="HH:mm"
|
|
||||||
value={notifyAfternoonValue}
|
|
||||||
allowClear={false}
|
|
||||||
minuteStep={5}
|
|
||||||
style={{ width: 96 }}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (!value) return;
|
|
||||||
setNotifyForm((old) => ({ ...old, afternoonSendTime: value.format('HH:mm') }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text type="secondary">核验「截止 16:00 前审核」的超时单(含历史未发),默认核验点 + 5 分钟</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
|
||||||
<span className="wst-notify__label">Webhook</span>
|
|
||||||
<Input
|
|
||||||
value={notifyForm.webhookUrl}
|
|
||||||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxxx"
|
|
||||||
onChange={(e) => setNotifyForm((old) => ({ ...old, webhookUrl: e.target.value }))}
|
|
||||||
style={{ flex: 1, minWidth: 380 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
|
||||||
<span className="wst-notify__label">加签密钥</span>
|
|
||||||
<Input.Password
|
|
||||||
value={notifyForm.webhookSecret}
|
|
||||||
placeholder={notifyConfig?.webhookSecretConfigured
|
|
||||||
? `已保存 ${notifyConfig.webhookSecretPreview || ''},留空表示不修改`
|
|
||||||
: 'SEC 开头;安全设置选「关键词 / IP」时留空'}
|
|
||||||
onChange={(e) => setNotifyForm((old) => ({ ...old, webhookSecret: e.target.value }))}
|
|
||||||
style={{ flex: 1, minWidth: 320 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
|
||||||
<span className="wst-notify__label">明细条数</span>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
max={notifyConfig?.maxDetailRowsLimit || 50}
|
|
||||||
precision={0}
|
|
||||||
value={notifyForm.maxDetailRows}
|
|
||||||
onChange={(value) => setNotifyForm((old) => ({ ...old, maxDetailRows: value ?? 0 }))}
|
|
||||||
style={{ width: 96 }}
|
|
||||||
/>
|
|
||||||
<Text type="secondary">
|
|
||||||
消息里最多列几条明细(0 = 只发汇总,每仓保底 1 条);钉钉文本上限 20000 字节,超了会自动精简
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
|
||||||
<span className="wst-notify__label">@所有人</span>
|
|
||||||
<Switch
|
|
||||||
checked={notifyForm.atAll}
|
|
||||||
onChange={(checked) => setNotifyForm((old) => ({ ...old, atAll: checked }))}
|
|
||||||
checkedChildren="开"
|
|
||||||
unCheckedChildren="关"
|
|
||||||
/>
|
|
||||||
<Text type="secondary">打开后每轮超时都会 @所有人(0 单超时也会推一条平安消息)</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wst-notify__row">
|
|
||||||
<span className="wst-notify__label">页面链接</span>
|
|
||||||
<Input
|
|
||||||
value={notifyForm.pageUrl}
|
|
||||||
placeholder="消息末尾「查看全部」用的地址(留空用默认生产地址)"
|
|
||||||
onChange={(e) => setNotifyForm((old) => ({ ...old, pageUrl: e.target.value }))}
|
|
||||||
style={{ flex: 1, minWidth: 380 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{notifyPreview ? (
|
|
||||||
<div className="wst-notify__preview">
|
|
||||||
<div className="wst-notify__preview-head">
|
|
||||||
<Text type="secondary">
|
|
||||||
{notifyPreview.roundLabel || '预览'}
|
|
||||||
{' '}| 超时 <b>{notifyPreview.rowCount}</b> 单
|
|
||||||
{' '}| 消息里列出 <b>{notifyPreview.listedCount}</b> 条
|
|
||||||
{' '}| {Math.round((notifyPreview.bytes / 1024) * 10) / 10} KB
|
|
||||||
{notifyPreview.truncated ? '(超长已精简)' : ''}
|
|
||||||
</Text>
|
|
||||||
<Button type="link" size="small" onClick={() => setNotifyPreview(null)}>收起</Button>
|
|
||||||
</div>
|
|
||||||
<pre>{notifyPreview.text || '(消息正文为空)'}</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="wst-notify__last">
|
|
||||||
<Text type="secondary">
|
|
||||||
上次推送:
|
|
||||||
{notifyLastState.lastSentAt
|
|
||||||
? <> {String(notifyLastState.lastSentAt).slice(0, 10)} {String(notifyLastState.lastSentAt).slice(11, 19)}
|
|
||||||
{' '}· {notifyLastState.lastRoundLabel || notifyLastState.lastRound || '—'}
|
|
||||||
{' '}· 超时 {notifyLastState.rowCount ?? 0} 单
|
|
||||||
{' '}· 列出 {notifyLastState.listedCount ?? '—'} 条
|
|
||||||
{notifyLastState.truncated ? '(超长已精简)' : ''}</>
|
|
||||||
: ' 还没有成功推送记录'}
|
|
||||||
</Text>
|
|
||||||
{notifyLastState.lastError ? (
|
|
||||||
<div className="wst-notify__error">
|
|
||||||
<Text type="danger">最近一次失败:{String(notifyLastState.lastError).slice(0, 300)}</Text>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapse size="small" items={NOTIFY_HELP_ITEMS} />
|
|
||||||
</Space>
|
|
||||||
</Spin>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Text type="secondary" className="wst-foot">
|
<Text type="secondary" className="wst-foot">
|
||||||
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
|
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
|
||||||
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
|
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
|
||||||
@ -1078,7 +535,7 @@ function WarehouseShipTimeliness() {
|
|||||||
发货 = 聚水潭回传的发货时间。
|
发货 = 聚水潭回传的发货时间。
|
||||||
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
|
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
|
||||||
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
||||||
<b>{excludedCarrierText}不考核</b>
|
<b>自提 / 中铁 / 壹米滴答不考核</b>
|
||||||
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
||||||
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -253,70 +253,3 @@
|
|||||||
gap: 2px 10px;
|
gap: 2px 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 钉钉通知(两轮核验完成后各推一次)───────────────────────────── */
|
|
||||||
.wst-notify__row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wst-notify__label {
|
|
||||||
flex: 0 0 88px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--sycm-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wst-notify__last {
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wst-notify__error {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wst-notify__help {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.8;
|
|
||||||
color: var(--sycm-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wst-notify__help code {
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04));
|
|
||||||
}
|
|
||||||
|
|
||||||
.wst-notify__preview {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--sycm-border, rgba(0, 0, 0, 0.08));
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wst-notify__preview-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wst-notify__preview pre {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--sycm-bg-container, #fff);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
/* 30 条明细大概 40 行,别把整个弹窗撑爆;内部滚动看全 */
|
|
||||||
max-height: 280px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user