Compare commits
No commits in common. "main" and "yanhui-wle-transfer-cache-20261023" have entirely different histories.
main
...
yanhui-wle
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* GET /api/warehouse-ship-timeliness/list
|
||||
* query:
|
||||
@ -11,33 +11,12 @@
|
||||
* GET /api/warehouse-ship-timeliness/rounds
|
||||
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
||||
*
|
||||
* GET /api/warehouse-ship-timeliness/export (2026-10-27 新增)
|
||||
* 「超时未发」明细导出:不分页、不截断,返回本轮全部行(与 /list 同一份缓存)
|
||||
* query: round / force
|
||||
*
|
||||
* ── 两轮核验完成后推钉钉群(2026-10-27 新增)────────────────────────────────
|
||||
* GET /api/warehouse-ship-timeliness/notify/settings 读配置(加签密钥只回掩码)
|
||||
* PUT /api/warehouse-ship-timeliness/notify/settings 保存配置(加签密钥留空 = 不变)
|
||||
* GET /api/warehouse-ship-timeliness/notify/state 上次推送结果
|
||||
* POST /api/warehouse-ship-timeliness/notify/run 立即推送一次(dryRun / probe / force)
|
||||
*
|
||||
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getShipTimeliness,
|
||||
listShipTimelinessExportRows,
|
||||
} = require('../services/warehouseShipTimelinessService');
|
||||
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
|
||||
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const {
|
||||
getShipTimelinessNotifyAdminConfig,
|
||||
getShipTimelinessNotifyRuntimeConfig,
|
||||
loadShipTimelinessNotifyState,
|
||||
missingFields,
|
||||
saveShipTimelinessNotifyConfig,
|
||||
} = require('../services/warehouseShipTimelinessNotifySettings');
|
||||
const { pushShipTimelinessNotify } = require('../jobs/warehouseShipTimelinessNotifyJob');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -80,95 +59,4 @@ router.get('/rounds', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 导出「超时未发」明细:**不分页、不截断**,返回本轮考核口径内的全部行。
|
||||
*
|
||||
* query: round(同 /list)、force=1(绕过缓存重查)
|
||||
*
|
||||
* 与 /list 共用 getShipTimeliness(同一份缓存),所以页面刚刷过时这里是秒回。
|
||||
* 前端拿到 rows 后按选中的仓在前端过滤 —— 口径与页面完全一致,不需要第二套 filter。
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const data = await listShipTimelinessExportRows(req.query || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出超时未发明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 两轮核验完成后推钉钉群 ──────────────────────────────────────────────────
|
||||
|
||||
// 读配置:加签密钥只回掩码 + 是否已配置,明文永不出后端
|
||||
router.get('/notify/settings', async (_req, res) => {
|
||||
try {
|
||||
const [config, state] = await Promise.all([
|
||||
getShipTimelinessNotifyAdminConfig(),
|
||||
loadShipTimelinessNotifyState(),
|
||||
]);
|
||||
res.json({ success: true, data: { ...config, lastState: state || {} } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '读取发货时效通知配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存配置;加签密钥留空表示「沿用已存的」,不必每次重填
|
||||
router.put('/notify/settings', async (req, res) => {
|
||||
try {
|
||||
const data = await saveShipTimelinessNotifyConfig(req.body || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) || 400;
|
||||
console.error('[WarehouseShipTimeliness] 保存通知配置失败:', error?.message);
|
||||
res.status(status).json({ success: false, error: error.message || '保存发货时效通知配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/notify/state', async (_req, res) => {
|
||||
try {
|
||||
const state = await loadShipTimelinessNotifyState();
|
||||
res.json({ success: true, data: state || {} });
|
||||
} catch (error) {
|
||||
sendError(res, error, '读取推送状态失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 立即推送一次(页面「立即推送」/「预览正文」/「测试通道」)。
|
||||
* body:
|
||||
* round 'NOON' | 'AFTERNOON',不传按当前时刻自动判定
|
||||
* dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径)
|
||||
* probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通)
|
||||
* force true = 忽略「本轮已推过」(默认手动触发就是 true,除非显式给 false)
|
||||
*/
|
||||
router.post('/notify/run', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const dryRun = body.dryRun === true || body.dryRun === 'true';
|
||||
const probe = body.probe === true || body.probe === 'true';
|
||||
const force = body.force === undefined ? true : (body.force === true || body.force === 'true');
|
||||
if (!dryRun) {
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
const missing = missingFields(config);
|
||||
if (missing.length) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `请先补全并保存:${missing.join(' / ')}`,
|
||||
data: { missingFields: missing },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await pushShipTimelinessNotify({
|
||||
dryRun,
|
||||
probe,
|
||||
force,
|
||||
round: body.round || undefined,
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
sendError(res, error, '推送发货时效通知失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -72,9 +72,6 @@ const {
|
||||
const {
|
||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
|
||||
const {
|
||||
startShipTimelinessNotifyScheduler,
|
||||
} = require('./jobs/warehouseShipTimelinessNotifyJob');
|
||||
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||
const { ensureLocalSchema } = require('./services/localSchema');
|
||||
const {
|
||||
@ -681,7 +678,7 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||
// 仓储中心发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
|
||||
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
|
||||
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
|
||||
@ -801,7 +798,6 @@ async function startServer(options = {}) {
|
||||
startAlibaba1688ImageSearchExpiryScheduler();
|
||||
startMkhReplenishAlertScheduler();
|
||||
startWarehouseLogisticsExceptionNotifyScheduler();
|
||||
startShipTimelinessNotifyScheduler();
|
||||
startDailyInventorySnapshotScheduler();
|
||||
|
||||
// Keep the first selection-pool page request from paying the remote schema
|
||||
|
||||
@ -245,40 +245,26 @@ function buildNotifyText(data = {}, options = {}) {
|
||||
const typeLine = buildTypeLine(data);
|
||||
if (typeLine) head.push(`类型:${typeLine}`);
|
||||
|
||||
// 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」——
|
||||
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
|
||||
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
|
||||
const freshness = data.freshness || {};
|
||||
if (totalPackages === 0) {
|
||||
if (freshness.emptyWindow && freshness.sourceStale) {
|
||||
const hours = Number(freshness.sourceAgeMinutes) > 0
|
||||
? `(已 ${Math.round((Number(freshness.sourceAgeMinutes) / 60) * 10) / 10} 小时未同步)`
|
||||
: '';
|
||||
head.push(`⚠️ 物流数据未同步:镜像表最后更新 ${freshness.sourceLastUpdate || '未知'}${hours},`
|
||||
+ `${batch.shipDate || '该日'} 的发货记录一条都没进库 —— 上面 0 单不代表当天没发货,请先到聚水潭核对。`);
|
||||
} else if (freshness.emptyWindow) {
|
||||
head.push(`${batch.shipDate || '该日'} 在物流镜像表里 0 条发货记录`
|
||||
+ `(表最后更新 ${freshness.sourceLastUpdate || '未知'},数据源本身正常)——`
|
||||
+ ' 若当天确有发货,说明上游漏同步了这一天,请到聚水潭核对。');
|
||||
} else if (shipped === 0) {
|
||||
head.push('本批发货 0 单,无异常可核。');
|
||||
} else {
|
||||
head.push('本批无转运异常,全部正常。');
|
||||
}
|
||||
head.push('本批无转运异常,全部正常。');
|
||||
}
|
||||
|
||||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||||
+ (Number(exclusions.voidedPackages) || 0)
|
||||
+ (Number(exclusions.nonRealPackages) || 0)
|
||||
+ (Number(exclusions.transferPackages) || 0);
|
||||
+ (Number(exclusions.nonRealPackages) || 0);
|
||||
if (excludedCount > 0) {
|
||||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单 / 转仓代发,不计考核)`);
|
||||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
|
||||
}
|
||||
// 现场拦截(售后已签收):货被快递当场拉回、售后仓已签收,不算异常;仍算发货分母
|
||||
const interceptCount = Number(exclusions.interceptPackages) || 0;
|
||||
if (interceptCount > 0) {
|
||||
head.push(`另有 ${interceptCount} 个包裹属现场拦截(售后已签收),不计异常`);
|
||||
}
|
||||
// 转仓代发(转x00005):记账仓下单、目标仓实际发出,本仓永远停在「未揽收」,不算异常
|
||||
const transferCount = Number(exclusions.transferPackages) || 0;
|
||||
if (transferCount > 0) {
|
||||
head.push(`另有 ${transferCount} 个包裹属转仓代发(转x00005),不计异常`);
|
||||
}
|
||||
|
||||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
|
||||
|
||||
@ -1,693 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效 · 两轮核验完成后把超时未发单推到钉钉群
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-27 原话):
|
||||
* 「发货时效也需要钉钉通知:13 点核验 7 点前的超时单,核验完成后通知;
|
||||
* 18:00 核验的订单,核验完成后也通知。」
|
||||
* ⟹ **一天两推**,各绑一个核验轮次(轮次定义见 warehouseShipTimelinessRegistry 的 ROUNDS):
|
||||
* NOON 13:00 核验「今天 07:00 前审核」→ 默认 13:05 推
|
||||
* AFTERNOON 18:00 核验「截止 16:00 前审核」→ 默认 18:05 推
|
||||
* ⚠️ 2026-10-28 起数据源换成订单两表(jst_Orders / jst_qmcust_orderlist),
|
||||
* 起算点是**审核时间** `last_oa_confirm_time`(不是订单创建时间);
|
||||
* 取不到审核时间的单回退创建时间,正文里在该时间后加 `*` 提示。
|
||||
* 推送正文写「审核 HH:mm」(此前一版误写「创建」,已纠正)。
|
||||
* ⚠️ 默认「核验时刻 + 5 分钟」:订单表 `updateTime` 得晚于核验时刻,
|
||||
* 整点推容易拿到 `freshness.coversDeadline=false`(数据没覆盖核验点,超时数偏大)。
|
||||
* 时刻在页面上可改,改完立即生效(每分钟 tick 比对,不用重启后端)。
|
||||
*
|
||||
* 消息内容:
|
||||
* 抬头(含「超时」字样,钉钉关键词安全设置要用)+ 汇总(超时单数 / 涉及仓数 / 最久已等)
|
||||
* + 不计考核(待审核·订单异常,不算仓库责任)+ 按仓超时单数 + 前 N 条明细 + 页面链接。
|
||||
* 明细一行 = 仓|内部单号|线上单号|快递|创建 HH:mm|已等 X 小时(业务对账用)。
|
||||
* (「快递」是业务 2026-10-27 要求加的,见 buildDetailLine 的说明)
|
||||
* 0 条也推 —— 群里看到「本轮 0 单超时」就知道任务在跑。
|
||||
*
|
||||
* 去重:状态存 system_config,按 `<北京日期>|<轮次键>` 去重(一天两轮互不影响)。
|
||||
* 推送成功才写状态;发送失败绝不写,否则业务配好通道后反而不推了。
|
||||
*
|
||||
* 时区:生产容器是 UTC,一切「今天 / 现在几点」都用 Asia/Shanghai 判。
|
||||
*/
|
||||
|
||||
const cron = require('node-cron');
|
||||
const {
|
||||
getShipTimeliness,
|
||||
DEFAULT_DETAIL_LIMIT,
|
||||
EXCLUDED_CARRIERS,
|
||||
} = require('../services/warehouseShipTimelinessService');
|
||||
const { ROUNDS, beijingNow, describeRounds } = require('../services/warehouseShipTimelinessRegistry');
|
||||
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
||||
const {
|
||||
DEFAULT_MAX_DETAIL_ROWS,
|
||||
DEFAULT_PAGE_URL,
|
||||
DEFAULT_SEND_TIME_AFTERNOON,
|
||||
DEFAULT_SEND_TIME_NOON,
|
||||
buildPushSlot,
|
||||
getShipTimelinessNotifyRuntimeConfig,
|
||||
isShipTimelinessNotifyReady,
|
||||
loadShipTimelinessNotifyState,
|
||||
saveShipTimelinessNotifyState,
|
||||
trimSentMap,
|
||||
} = require('../services/warehouseShipTimelinessNotifySettings');
|
||||
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const DEFAULT_TICK_CRON = '* * * * *';
|
||||
// 钉钉 text 上限 20000 字节;留 300 字节给尾巴,避免临界失败
|
||||
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
||||
// 启动补推窗口:后端在 13:05 那一刻没跑(重启 / 部署),30 分钟内起来还补一次
|
||||
const CATCH_UP_WINDOW_MINUTES = 30;
|
||||
|
||||
let tickRunning = false;
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 北京时间的 YYYY-MM-DD */
|
||||
function chinaDateKey(date = new Date()) {
|
||||
const base = date instanceof Date ? date : new Date(date);
|
||||
if (!Number.isFinite(base.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(base);
|
||||
}
|
||||
|
||||
/** 北京时间的 HH:mm —— 用来跟配置里的推送时刻比对 */
|
||||
function chinaHourMinute(date = new Date()) {
|
||||
const base = date instanceof Date ? date : new Date(date);
|
||||
if (!Number.isFinite(base.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TIMEZONE,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(base);
|
||||
}
|
||||
|
||||
/** 北京时间的 HH:mm 换算成「当天第几分钟」,方便做 +/- 比较 */
|
||||
function minutesOfDay(date = new Date()) {
|
||||
const text = chinaHourMinute(date);
|
||||
if (!text) return null;
|
||||
const [hour, minute] = text.split(':').map((part) => Number(part));
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
function parseSendTime(text) {
|
||||
const [hour, minute] = String(text || '').split(':').map((part) => Number(part));
|
||||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
/** 字段清洗:换行 / 连续空白压成单空格(避免一条明细被劈成两行) */
|
||||
function cleanCell(value, fallback = '') {
|
||||
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
/** 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」,与页面同一套写法 */
|
||||
function formatOverdue(minutes) {
|
||||
const m = Number(minutes);
|
||||
if (!Number.isFinite(m) || m <= 0) return '—';
|
||||
if (m < 60) return `${m} 分钟`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rest = m % 60;
|
||||
if (h < 24) return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
|
||||
const d = Math.floor(h / 24);
|
||||
const hr = h % 24;
|
||||
return hr ? `${d} 天 ${hr} 小时` : `${d} 天`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起算时间(= 审核时间 `last_oa_confirm_time`)的展示:
|
||||
* 本轮的日期只给 HH:mm,跨天(历史未发单)必须带 MM-DD。
|
||||
* 🔥 为什么要带日期:13:00 轮窗口上界是 07:00,但口径「含历史未发」,
|
||||
* 明细里会出现昨天 16:13 审核的单 —— 只写「审核 16:13」会被读成「今天 16:13」,
|
||||
* 看着像「审核时间晚于窗口,不该算超时」,业务会对不上账。
|
||||
*
|
||||
* ⚠️ 传进来的 `auditTime` 是服务层的 benchmarkAt:审核时间,取不到时回退订单创建时间
|
||||
* (此时 row.benchmarkSource === 'created',调用方会给时间加 `*`)。
|
||||
*/
|
||||
function auditWhen(auditTime, roundDate) {
|
||||
const text = cleanCell(auditTime);
|
||||
if (!text) return '';
|
||||
const hit = text.match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||||
if (!hit) {
|
||||
const clock = text.match(/(\d{2}:\d{2})/);
|
||||
return clock ? clock[1] : text;
|
||||
}
|
||||
const [, , month, day, hour, minute] = hit;
|
||||
const sameDay = Boolean(roundDate) && String(roundDate).slice(5, 10) === `${month}-${day}`;
|
||||
return sameDay ? `${hour}:${minute}` : `${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细一行:仓|内部单号|线上单号|快递|审核 时间|已等 X 小时
|
||||
* ⚠️ 第二段是**内部单号**(聚水潭 o_id,仓管在系统里按它查单),第三段是线上单号(客服对客户)。
|
||||
* 两者业务都要,别只留一个。
|
||||
* 🔥 第四段「快递」是业务 2026-10-27 要求加的:群里看到超时单要能直接判断
|
||||
* 是哪个快递、是不是得走站点(比如安能系、德邦、京东各区网点揽收节奏完全不同),
|
||||
* 不用再回页面一单单点开确认。
|
||||
* 🔥 2026-10-28 第五段是「审核」时间(起算点),不是订单创建时间;
|
||||
* 审核时间缺失、回退订单创建时间的单,时间后面带 `*`。
|
||||
* ⚠️ 跨天要带日期,见 auditWhen 的说明。
|
||||
*/
|
||||
function buildDetailLine(row, index, roundDate) {
|
||||
const when = auditWhen(row?.auditTime, roundDate);
|
||||
const auditText = when
|
||||
? `审核 ${when}${row?.benchmarkSource === 'created' ? '*' : ''}`
|
||||
: '审核 —';
|
||||
const parts = [
|
||||
cleanCell(row?.warehouseName || row?.centerName, '未知仓'),
|
||||
cleanCell(row?.oId, '无单号'),
|
||||
cleanCell(row?.soId, '—'),
|
||||
cleanCell(row?.logisticCompany, '—'),
|
||||
auditText,
|
||||
`已等 ${formatOverdue(row?.overdueMinutes)}`,
|
||||
];
|
||||
return `${index}. ${parts.join('|')}`;
|
||||
}
|
||||
|
||||
/** 按仓分桶(用主仓名),组间按超时单数降序,组内保持服务层原顺序(已按时长降序) */
|
||||
function bucketRowsByCenter(rows) {
|
||||
const buckets = new Map();
|
||||
const firstSeen = new Map();
|
||||
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
|
||||
const key = cleanCell(row?.centerName || row?.warehouseName, '未知仓');
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, []);
|
||||
firstSeen.set(key, index);
|
||||
}
|
||||
buckets.get(key).push(row);
|
||||
});
|
||||
return Array.from(buckets.entries())
|
||||
.map(([key, items]) => ({ key, items, index: firstSeen.get(key) }))
|
||||
.sort((a, b) => b.items.length - a.items.length || a.index - b.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按仓分配明细额度,返回已按仓聚集的行。
|
||||
* 同 WLE 的理由:某仓超时特别多时会一口气吃满额度,其它仓一条都看不到。
|
||||
* ① 仓数 ≥ 额度 → 每仓 1 条;
|
||||
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法)。
|
||||
*/
|
||||
function pickDetailRows(rows, limit) {
|
||||
const groups = bucketRowsByCenter(rows);
|
||||
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
|
||||
const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total));
|
||||
|
||||
if (quota === 0) return [];
|
||||
if (quota >= total) return groups.flatMap((group) => group.items);
|
||||
if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]);
|
||||
|
||||
const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 }));
|
||||
const remaining = quota - groups.length;
|
||||
const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0);
|
||||
|
||||
if (remaining > 0 && leftTotal > 0) {
|
||||
alloc.forEach((item) => {
|
||||
item.exact = (item.left / leftTotal) * remaining;
|
||||
item.extra = Math.min(item.left, Math.floor(item.exact));
|
||||
});
|
||||
let assigned = alloc.reduce((sum, item) => sum + item.extra, 0);
|
||||
[...alloc]
|
||||
.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))
|
||||
.forEach((item) => {
|
||||
if (assigned >= remaining || item.extra >= item.left) return;
|
||||
item.extra += 1;
|
||||
assigned += 1;
|
||||
});
|
||||
alloc.forEach((item) => {
|
||||
item.take += item.extra;
|
||||
});
|
||||
}
|
||||
|
||||
return alloc.flatMap((item) => item.group.items.slice(0, item.take));
|
||||
}
|
||||
|
||||
/** 按仓汇总块:只列超时 > 0 的仓,降序,最多 12 行 */
|
||||
function buildCenterLines(data = {}) {
|
||||
return (data.centers || [])
|
||||
.map((center) => ({
|
||||
name: cleanCell(center.name || center.key, '未知仓'),
|
||||
overdue: Number(center.overdueUnshipped) || 0,
|
||||
oldestDays: Number(center.oldestDays) || 0,
|
||||
}))
|
||||
.filter((item) => item.overdue > 0)
|
||||
.sort((a, b) => b.overdue - a.overdue)
|
||||
.slice(0, 12)
|
||||
.map((item, index) => (
|
||||
`${index + 1}. ${item.name} ${item.overdue} 单`
|
||||
+ (item.oldestDays > 0 ? `(最久 ${item.oldestDays} 天)` : '')
|
||||
));
|
||||
}
|
||||
|
||||
function buildPageLink(pageUrl, roundKey) {
|
||||
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
||||
if (!roundKey) return base;
|
||||
const glue = base.includes('?') ? '&' : '?';
|
||||
return `${base}${glue}round=${encodeURIComponent(roundKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装群消息正文(纯函数,便于单测)。
|
||||
*
|
||||
* @param {object} data getShipTimeliness 的返回
|
||||
* @param {object} options maxDetailRows / pageUrl / roundKey
|
||||
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
|
||||
*/
|
||||
function buildNotifyText(data = {}, options = {}) {
|
||||
const summary = data.summary || {};
|
||||
const round = data.round || {};
|
||||
const window = data.window || {};
|
||||
const rows = Array.isArray(data.orders) ? data.orders : [];
|
||||
const freshness = data.freshness || {};
|
||||
|
||||
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
|
||||
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
|
||||
: DEFAULT_MAX_DETAIL_ROWS;
|
||||
const roundKey = options.roundKey || round.key || '';
|
||||
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, roundKey);
|
||||
|
||||
const overdue = Number(summary.overdueUnshipped) || 0;
|
||||
const centerCount = Number(summary.overdueCenterCount) || 0;
|
||||
const oldestDays = Number(summary.oldestDays) || 0;
|
||||
const heldCount = Number(summary.heldCount) || 0;
|
||||
const roundLabel = cleanCell(round.label, '本轮核验');
|
||||
const windowEnd = String(window.end || '').slice(11, 16);
|
||||
|
||||
const head = [
|
||||
`【仓储中心发货时效】${roundLabel}${windowEnd ? ` · 核验 ${windowEnd} 前审核` : ''}`,
|
||||
`超时未发货 ${overdue} 单,涉及 ${centerCount} 个仓`
|
||||
+ (oldestDays > 0 ? `,最久已等 ${oldestDays} 天` : ''),
|
||||
];
|
||||
|
||||
if (overdue === 0) {
|
||||
head.push('本轮没有超时未发货的订单,全部按时发出。');
|
||||
}
|
||||
if (heldCount > 0) {
|
||||
head.push(`另有 ${heldCount} 单不计考核(待审核 / 订单异常)`);
|
||||
}
|
||||
const carrierSkipped = Number(summary.carrierSkipped) || 0;
|
||||
if (carrierSkipped > 0) {
|
||||
// 快递清单由服务层给(`excludedCarriers`),别在这里写死 ——
|
||||
// 2026-10-27 加「武进安能」时就吃过一次:写死的文案忘了改,群里口径和页面不一致。
|
||||
const carriers = (Array.isArray(data.excludedCarriers) ? data.excludedCarriers : EXCLUDED_CARRIERS)
|
||||
.filter(Boolean);
|
||||
head.push(`已剔除 ${carrierSkipped} 单不考核快递${carriers.length ? `(${carriers.join(' / ')})` : ''}`);
|
||||
}
|
||||
// 数据没覆盖本轮核验时刻 → 数字必然偏大,必须写在消息里,不能让仓管拿错数去追单
|
||||
if (freshness.coversDeadline === false) {
|
||||
head.push(
|
||||
`⚠️ 数据快照只到 ${String(freshness.stateMaxQuery || '').slice(11, 16) || '未知时间'},`
|
||||
+ `尚未覆盖本轮核验时刻 ${String(data.deadline || '').slice(11, 16)},超时单量偏大,请以聚水潭实时状态为准。`
|
||||
);
|
||||
}
|
||||
|
||||
const centerLines = overdue > 0 ? buildCenterLines(data) : [];
|
||||
const roundDate = String(data.date || round.date || '').slice(0, 10);
|
||||
const detailAll = pickDetailRows(rows, maxDetailRows)
|
||||
.map((row, index) => buildDetailLine(row, index + 1, roundDate));
|
||||
const wantCount = detailAll.length;
|
||||
|
||||
const buildTail = (listed) => {
|
||||
const lines = [];
|
||||
if (overdue > 0) {
|
||||
if (listed === 0) {
|
||||
lines.push('');
|
||||
lines.push(`明细共 ${overdue} 条,消息里不放,请点下面链接查看。`);
|
||||
} else if (overdue > listed) {
|
||||
lines.push(`…其余 ${overdue - listed} 条见页面。`);
|
||||
}
|
||||
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const assemble = (listed, withCenters) => {
|
||||
const blocks = [...head];
|
||||
if (withCenters && centerLines.length) {
|
||||
blocks.push('');
|
||||
blocks.push('按仓(超时单):');
|
||||
blocks.push(...centerLines);
|
||||
}
|
||||
if (listed > 0) {
|
||||
blocks.push('');
|
||||
blocks.push(`明细前 ${listed} 条:`);
|
||||
blocks.push(...detailAll.slice(0, listed));
|
||||
}
|
||||
const tail = buildTail(listed);
|
||||
if (tail.length) blocks.push(...tail);
|
||||
const text = blocks.join('\n');
|
||||
return { text, bytes: Buffer.byteLength(text, 'utf8') };
|
||||
};
|
||||
|
||||
// 超长就从明细末尾往前砍(先砍明细,再砍分仓块)
|
||||
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」
|
||||
let listed = wantCount;
|
||||
let withCenters = centerLines.length > 0;
|
||||
let result = assemble(listed, withCenters);
|
||||
let truncated = false;
|
||||
|
||||
while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) {
|
||||
listed -= 1;
|
||||
truncated = true;
|
||||
result = assemble(listed, withCenters);
|
||||
}
|
||||
if (result.bytes > SAFE_CONTENT_BYTES && withCenters) {
|
||||
withCenters = false;
|
||||
result = assemble(listed, withCenters);
|
||||
}
|
||||
if (result.bytes > SAFE_CONTENT_BYTES) {
|
||||
const chars = Array.from(result.text);
|
||||
let bytes = 0;
|
||||
let cut = '';
|
||||
for (const char of chars) {
|
||||
const size = Buffer.byteLength(char, 'utf8');
|
||||
if (bytes + size > SAFE_CONTENT_BYTES) break;
|
||||
bytes += size;
|
||||
cut += char;
|
||||
}
|
||||
result = { text: cut, bytes };
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
rowCount: overdue,
|
||||
listedCount: listed,
|
||||
bytes: result.bytes,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** 取某一轮次今天的元信息(deadline / windowEnd / label) */
|
||||
function resolveRoundMeta(roundKey, now = new Date()) {
|
||||
const key = String(roundKey || '').toUpperCase();
|
||||
const target = ROUNDS.find((round) => round.key === key);
|
||||
if (!target) return null;
|
||||
const described = describeRounds(now).find((round) => round.key === key);
|
||||
if (!described) return null;
|
||||
return {
|
||||
key: target.key,
|
||||
label: described.label || target.label,
|
||||
date: described.date,
|
||||
verifyHour: target.verifyHour,
|
||||
deadline: described.deadline,
|
||||
windowEnd: described.windowEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/** 某个轮次今天该在几点推(读配置) */
|
||||
function sendTimeForRound(config, roundKey) {
|
||||
const key = String(roundKey || '').toUpperCase();
|
||||
if (key === 'AFTERNOON') return config.afternoonSendTime || DEFAULT_SEND_TIME_AFTERNOON;
|
||||
return config.noonSendTime || DEFAULT_SEND_TIME_NOON;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次推送。参数:
|
||||
* round 'NOON' | 'AFTERNOON'(不传 = 按当前时刻自动判定)
|
||||
* now 指定时间(测试用)
|
||||
* force 忽略「今天这轮已推过」(页面「立即推送」用)
|
||||
* dryRun 只组装消息不发送(预览 / 单测用)
|
||||
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
||||
*/
|
||||
async function pushShipTimelinessNotify(options = {}) {
|
||||
// 🔥 两个「现在」不能混用(生产容器 /etc/timezone = Etc/UTC):
|
||||
// · rawNow —— 真实时刻。凡是走 Intl(Asia/Shanghai) 或 toISOString 的地方用它;
|
||||
// · bjNow —— 平移成「本地 getter 读出来正好是北京时间」的 Date。
|
||||
// registry.describeRounds / resolveRoundMeta / getShipTimeliness 内部都用
|
||||
// `getDate()/getHours()` 直接读,必须喂 bjNow —— 否则北京 00:00~08:00 会取到前一天
|
||||
// (2026-10-21 生产踩过:13:00 轮算出 3895 单,实际只有 6 单)。
|
||||
const rawNow = options.now === undefined ? new Date() : new Date(options.now);
|
||||
const bjNow = beijingNow(rawNow);
|
||||
const pushDate = chinaDateKey(rawNow);
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
|
||||
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
||||
if (options.probe === true) {
|
||||
if (!isShipTimelinessNotifyReady(config)) {
|
||||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||||
}
|
||||
const delivery = await sendTextToWebhook({
|
||||
webhookUrl: config.webhookUrl,
|
||||
secret: config.webhookSecret,
|
||||
content: '【仓储中心发货时效】通知通道测试:收到这条说明 webhook 配置可用。',
|
||||
});
|
||||
return { sent: true, probe: true, delivery };
|
||||
}
|
||||
|
||||
// 轮次:显式指定优先;否则按当前时刻自动判定(13:00 前 = 没有轮次)
|
||||
const explicitRound = String(options.round || '').toUpperCase();
|
||||
let roundKey = explicitRound;
|
||||
if (!roundKey) {
|
||||
const minutes = minutesOfDay(rawNow);
|
||||
let active = null;
|
||||
for (const round of ROUNDS) {
|
||||
if (minutes >= round.verifyHour * 60) active = round;
|
||||
}
|
||||
if (!active) return { sent: false, reason: 'not_started' };
|
||||
roundKey = active.key;
|
||||
}
|
||||
|
||||
const meta = resolveRoundMeta(roundKey, bjNow);
|
||||
if (!meta) return { sent: false, reason: 'unknown_round', round: roundKey };
|
||||
|
||||
// 还没到本轮核验时刻就推 = 推的是半成品,直接跳过(等下一轮 tick)
|
||||
const deadlineMinutes = parseSendTime(String(meta.deadline || '').slice(11, 16));
|
||||
if (deadlineMinutes != null && minutesOfDay(rawNow) < deadlineMinutes) {
|
||||
return { sent: false, reason: 'before_deadline', round: roundKey, deadline: meta.deadline };
|
||||
}
|
||||
|
||||
const slot = buildPushSlot(pushDate, roundKey);
|
||||
const state = await loadShipTimelinessNotifyState();
|
||||
const sentMap = (state && typeof state.sent === 'object' && state.sent) ? state.sent : {};
|
||||
if (sentMap[slot] && options.force !== true) {
|
||||
return {
|
||||
sent: false,
|
||||
reason: 'already_sent',
|
||||
slot,
|
||||
lastSentAt: sentMap[slot].sentAt || null,
|
||||
round: roundKey,
|
||||
};
|
||||
}
|
||||
if (!config.enabled && options.force !== true) {
|
||||
return { sent: false, reason: 'disabled', round: roundKey };
|
||||
}
|
||||
|
||||
const dryRun = options.dryRun === true;
|
||||
if (!dryRun && !isShipTimelinessNotifyReady(config)) {
|
||||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||||
}
|
||||
|
||||
// 明细条数用默认 500(= 页面同一档),这样与页面共用结果缓存:
|
||||
// 推送完业务点进页面几乎是热的,不会为了推一条消息额外扫一次订单视图。
|
||||
// summary/centers 都是全量算的,limit 只截断明细,不影响数字。
|
||||
const data = await getShipTimeliness({
|
||||
round: roundKey,
|
||||
limit: DEFAULT_DETAIL_LIMIT,
|
||||
now: bjNow,
|
||||
});
|
||||
if (data?.notStarted) {
|
||||
return { sent: false, reason: 'not_started', round: roundKey };
|
||||
}
|
||||
|
||||
const built = buildNotifyText(data, {
|
||||
maxDetailRows: config.maxDetailRows,
|
||||
pageUrl: config.pageUrl,
|
||||
roundKey,
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
sent: false,
|
||||
reason: 'dry_run',
|
||||
round: roundKey,
|
||||
roundLabel: data.round?.label || meta.label,
|
||||
deadline: data.deadline || meta.deadline,
|
||||
text: built.text,
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
summary: data.summary || null,
|
||||
freshness: data.freshness || null,
|
||||
};
|
||||
}
|
||||
|
||||
const delivery = await sendTextToWebhook({
|
||||
webhookUrl: config.webhookUrl,
|
||||
secret: config.webhookSecret,
|
||||
content: built.text,
|
||||
atAll: config.atAll === true,
|
||||
});
|
||||
|
||||
const nextSent = {
|
||||
...sentMap,
|
||||
[slot]: {
|
||||
sentAt: rawNow.toISOString(),
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
deadline: data.deadline || meta.deadline || '',
|
||||
roundLabel: data.round?.label || meta.label || '',
|
||||
},
|
||||
};
|
||||
|
||||
await saveShipTimelinessNotifyState({
|
||||
version: 1,
|
||||
sent: trimSentMap(nextSent),
|
||||
lastSentAt: rawNow.toISOString(),
|
||||
lastStatus: 'sent',
|
||||
lastError: '',
|
||||
lastRound: roundKey,
|
||||
lastRoundLabel: data.round?.label || meta.label || '',
|
||||
lastSlot: slot,
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
});
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
round: roundKey,
|
||||
roundLabel: data.round?.label || meta.label,
|
||||
slot,
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
text: built.text,
|
||||
summary: data.summary || null,
|
||||
delivery,
|
||||
};
|
||||
}
|
||||
|
||||
async function runShipTimelinessNotifyJob(source = 'scheduled', options = {}) {
|
||||
try {
|
||||
const result = await pushShipTimelinessNotify(options);
|
||||
if (result.sent) {
|
||||
console.log(
|
||||
`[WstNotify] ${source} 已推送 ${result.round}(超时 ${result.rowCount} 单,列出 ${result.listedCount} 条,${result.bytes} 字节)`
|
||||
);
|
||||
} else {
|
||||
console.log(`[WstNotify] ${source} 跳过: ${result.reason}${result.round ? ` (${result.round})` : ''}`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[WstNotify] ${source} 执行失败:`, error.message || error);
|
||||
try {
|
||||
await saveShipTimelinessNotifyState({
|
||||
...(await loadShipTimelinessNotifyState()),
|
||||
lastError: String(error.message || error).slice(0, 500),
|
||||
lastFailedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (_ignored) { /* 状态写失败不影响主流程 */ }
|
||||
return { sent: false, reason: 'error', error: error.message || String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断此刻该推哪些轮次(返回数组 —— 两个时刻万一配成同一个 HH:mm,两轮都要推)。
|
||||
* · 精确命中 HH:mm → 推
|
||||
* · 刚启动的补推窗口(sendTime ~ sendTime+30min 内)→ 推(是否重复由调用方按 slot 去重)
|
||||
* 之所以不用固定 cron:业务在页面上改时刻后要立即生效,不能等重启。
|
||||
*/
|
||||
function resolveDueRounds(config, now = new Date(), { catchUp = false } = {}) {
|
||||
const minutes = minutesOfDay(now);
|
||||
if (minutes == null) return [];
|
||||
|
||||
const due = [];
|
||||
for (const round of ROUNDS) {
|
||||
const target = parseSendTime(sendTimeForRound(config, round.key));
|
||||
if (target == null) continue;
|
||||
if (minutes === target) {
|
||||
due.push(round.key);
|
||||
continue;
|
||||
}
|
||||
if (catchUp && minutes > target && minutes <= target + CATCH_UP_WINDOW_MINUTES) {
|
||||
due.push(round.key);
|
||||
}
|
||||
}
|
||||
return due;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度:每分钟 tick,比对两个轮次各自的推送时刻。
|
||||
* 启动时额外做一次「补推」检查:13:05 那一刻后端没在跑(重启 / 部署)也能补上。
|
||||
*/
|
||||
function startShipTimelinessNotifyScheduler() {
|
||||
const disabled = String(process.env.DISABLE_WST_NOTIFY || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] 发货时效钉钉推送已禁用');
|
||||
return;
|
||||
}
|
||||
const expression = String(process.env.WST_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
||||
|
||||
const tick = async ({ catchUp = false } = {}) => {
|
||||
if (tickRunning) return;
|
||||
tickRunning = true;
|
||||
try {
|
||||
const now = new Date();
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig();
|
||||
if (!config.enabled) return;
|
||||
const due = resolveDueRounds(config, now, { catchUp });
|
||||
if (!due.length) return;
|
||||
|
||||
const state = catchUp ? await loadShipTimelinessNotifyState() : null;
|
||||
for (const roundKey of due) {
|
||||
if (catchUp) {
|
||||
// 补推只在「今天这轮确实还没推过」时才动手,避免重启就把消息重发一遍
|
||||
const slot = buildPushSlot(chinaDateKey(now), roundKey);
|
||||
if (state?.sent && state.sent[slot]) continue;
|
||||
console.log(`[WstNotify] 启动补推检查命中 ${roundKey}`);
|
||||
}
|
||||
await runShipTimelinessNotifyJob(catchUp ? 'catch_up' : 'scheduled', { now, round: roundKey });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WstNotify] 定时检查失败:', error.message || error);
|
||||
} finally {
|
||||
tickRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
cron.schedule(expression, () => { tick(); }, { timezone: TIMEZONE });
|
||||
|
||||
// 启动后 45s 跑一次补推检查(等业务库/隧道就绪,别和启动预热抢)
|
||||
setTimeout(() => { tick({ catchUp: true }); }, 45 * 1000);
|
||||
|
||||
console.log(
|
||||
`[Scheduler] 发货时效钉钉推送已注册: 每分钟检查,13:00 轮→默认 13:05、18:00 轮→默认 18:05 (${TIMEZONE})`
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CATCH_UP_WINDOW_MINUTES,
|
||||
DEFAULT_TICK_CRON,
|
||||
SAFE_CONTENT_BYTES,
|
||||
TIMEZONE,
|
||||
auditWhen,
|
||||
buildCenterLines,
|
||||
buildDetailLine,
|
||||
buildNotifyText,
|
||||
buildPageLink,
|
||||
bucketRowsByCenter,
|
||||
chinaDateKey,
|
||||
chinaHourMinute,
|
||||
cleanCell,
|
||||
formatOverdue,
|
||||
minutesOfDay,
|
||||
parseSendTime,
|
||||
pickDetailRows,
|
||||
pushShipTimelinessNotify,
|
||||
resolveDueRounds,
|
||||
resolveRoundMeta,
|
||||
runShipTimelinessNotifyJob,
|
||||
sendTimeForRound,
|
||||
startShipTimelinessNotifyScheduler,
|
||||
};
|
||||
@ -4,7 +4,7 @@
|
||||
* 仓储中心物流转运异常(2026-10-21 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-21):
|
||||
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心发货时效」下面),
|
||||
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
|
||||
* 下属 12 个发货仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
|
||||
@ -158,7 +158,7 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
|
||||
{ keyword: '转空包', label: '信誉单' },
|
||||
]);
|
||||
|
||||
// ── 转仓代发(业务 2026-10-23 指定不核对异常;2026-10-24 补充不计原仓发货量)──
|
||||
// ── 转仓代发(业务 2026-10-23 指定:不用核对异常)──────────────────────────
|
||||
// 聚水潭「转仓」操作会在 `order_labels` 留一条 `转x00005` 形式的标签
|
||||
// (x + 目标仓编码)。这类单**记账仓是原仓**(如崔桥工厂仓),货却由目标仓
|
||||
// (x00005 = 曹县)实际发出 —— 承运账号也跟着变成曹县韵达小件。
|
||||
@ -169,9 +169,8 @@ const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
|
||||
// 快递分布:曹县韵达小件 133、邮政快递包裹POSTB 124、睢宁中通 41 ……
|
||||
// 目前只出现过 `转x00005` 一种编码,但标签是机器生成的(后续可能出现 x00006),
|
||||
// 所以用正则匹配「转x<3位以上数字>」,不写死单个编码。
|
||||
// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**。
|
||||
// 🔥 口径:**整单不计** —— 货不是从原仓走的,既不算原仓异常,也不进原仓发货分母
|
||||
// (记进分母会把分母做大、异常率被稀释)。这与「现场拦截」相反,后者货是本仓发的、留分母。
|
||||
// ⚠️ 与「转空包」区分:转空包是发空包(信誉单,假发货);转仓代发是**真发货**,
|
||||
// 只是不从记账仓发 —— 所以保留发货分母,只从异常清单摘掉(同「现场拦截」)。
|
||||
const TRANSFER_WAREHOUSE_LABEL_PATTERN = /转\s*[xX]\s*\d{3,}/;
|
||||
|
||||
// ── 现场拦截(货已被售后仓签收)不算转运异常(业务 2026-10-23 指定)────────
|
||||
|
||||
@ -38,10 +38,6 @@ const EXPORT_DETAIL_LIMIT = 5000;
|
||||
|
||||
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
||||
const SNAPSHOT_STALE_MINUTES = 120;
|
||||
// 数据源(整张镜像表)多久没刷新算停更。与上面同值,但语义不同:
|
||||
// SNAPSHOT_STALE_MINUTES 判的是「本批窗口内行的 query_time」,
|
||||
// SOURCE_STALE_MINUTES 判的是「整张表最后被灌库的时刻」—— 窗口内 0 行时只能靠后者。
|
||||
const SOURCE_STALE_MINUTES = Number(process.env.CANGKU_WLE_SOURCE_STALE_MINUTES) || 120;
|
||||
|
||||
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
|
||||
@ -152,36 +148,6 @@ async function loadShippedPackages(batch) {
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 整张镜像表最后被灌库的时刻(表级新鲜度)。
|
||||
*
|
||||
* 🔥 2026-11-02 新增,起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
|
||||
* 业务问「怎么可能 0 单」。查出来镜像表 **11-01 07:55 之后就没再灌过**,
|
||||
* 而 v1 的 freshness 只用「本批窗口内行的 query_time」算 —— 窗口一行都没有时
|
||||
* `snapshotAt` 是空串 → `stale=false`、`hint=''`,页面和钉钉于是把它当成
|
||||
* 「本批确实没有发货 / 全部正常」。这是最危险的一种假绿。
|
||||
*
|
||||
* 成本:全表 `MAX(query_time)` 在一个 402 万行的视图上实测 **13.5s**(谓词下推差),
|
||||
* 所以**只在窗口 0 行时才调**(正常批次一行都不用付这笔钱),结果随主结果进 5 分钟缓存。
|
||||
*
|
||||
* @returns {Promise<string>} 'YYYY-MM-DD HH:mm:ss',取不到返回空串
|
||||
*/
|
||||
async function loadSourceLastUpdate() {
|
||||
try {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(e.query_time) AS max_q
|
||||
FROM dbo.jst_order_express_details e WITH (NOLOCK)`,
|
||||
{},
|
||||
{ timeoutMs: 240000 }
|
||||
);
|
||||
return formatDateTime(rows?.[0]?.max_q);
|
||||
} catch (err) {
|
||||
// 探测失败不该让整页挂掉:退回「不知道」,hint 里会说明取不到表级时间
|
||||
console.warn(`[WLE] 数据源新鲜度探测失败:${err.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 售后拦截判定:一次 IN 多少单 + 查询超时。
|
||||
// 这两张售后表在 rpt 副本里**都是堆表(零索引)**,`o_id IN (...)` 只能全表扫,
|
||||
// 实测 125 万行的 returnAndRefund 4.5s、176 万行的 qmcust 8.5s —— 所以:
|
||||
@ -349,17 +315,7 @@ async function getLogisticsExceptions(options = {}) {
|
||||
orders: [],
|
||||
detailLimit: limit,
|
||||
truncated: false,
|
||||
freshness: {
|
||||
snapshotAt: '',
|
||||
ageMinutes: null,
|
||||
stale: false,
|
||||
coversDeadline: null,
|
||||
emptyWindow: false,
|
||||
sourceLastUpdate: '',
|
||||
sourceAgeMinutes: null,
|
||||
sourceStale: false,
|
||||
hint: '',
|
||||
},
|
||||
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
|
||||
generatedAt,
|
||||
cached: false,
|
||||
cacheAgeSec: 0,
|
||||
@ -452,18 +408,6 @@ async function getLogisticsExceptions(options = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓实际发出。
|
||||
// 业务 2026-10-24 定版:**原仓的发货量也不该算** —— 货不是从这个仓走的,
|
||||
// 记在原仓只会把发货分母做大、异常率稀释。所以按「整单不计」处理
|
||||
// (既不算异常、也不进发货分母),与承运商/刷单那几类同级,不是「只摘异常」。
|
||||
const transferReason = registry.matchTransferWarehouse(row.order_labels);
|
||||
if (transferReason) {
|
||||
skippedByTransfer.packages += 1;
|
||||
skippedByTransfer.orders.add(oId);
|
||||
skippedByTransfer.reasons[transferReason] = (skippedByTransfer.reasons[transferReason] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = centerStat.get(center.key);
|
||||
stat.shippedOrders.add(oId);
|
||||
stat.packages += 1;
|
||||
@ -475,10 +419,21 @@ async function getLogisticsExceptions(options = {}) {
|
||||
|
||||
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||||
|
||||
// ④ 转仓代发(`转x00005` 这类标签):记账仓是原仓,货由目标仓发出,
|
||||
// 原仓物流状态必然停在「未揽收 / 无物流单」→ 全是假异常(业务 2026-10-23 指定不核对)。
|
||||
// ⚠️ 与 a/b/c 三类不同:货**确实发了**,所以保留发货分母,只从异常清单摘掉。
|
||||
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;
|
||||
}
|
||||
|
||||
// ⑤ 现场拦截:售后单有签收单号 + 实退数量 > 0 —— 货已被售后仓签收,
|
||||
// 物流环节其实结束了(派送时被拦下、原车拉回),不算「卡在网点发不出去」。
|
||||
// ⚠️ 与上面几类不同:这里**保留发货分母**(货确实从这个仓发出去了),
|
||||
// 只从异常清单里摘掉,异常率才不会被错误地放大。
|
||||
// ⚠️ 同④:**保留发货分母**(货确实发出去了),只从异常清单里摘掉,
|
||||
// 异常率才不会被错误地放大。
|
||||
if (interceptedOrders.has(oId)) {
|
||||
skippedByIntercept.packages += 1;
|
||||
skippedByIntercept.orders.add(oId);
|
||||
@ -554,33 +509,11 @@ async function getLogisticsExceptions(options = {}) {
|
||||
}
|
||||
|
||||
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
||||
// 🔥 emptyWindow = 本批窗口里**一行都没取到**(12 仓范围)。
|
||||
// 这时 snapshotAt 必然是空串,v1 的判据(coversDeadline=null、stale=false、hint='')
|
||||
// 会让「上游没同步」和「当天真的没发货」长得一模一样,页面/钉钉都显示成 0 单全绿。
|
||||
// 所以窗口为空时补查一次**表级**最后灌库时刻,把两种情形分开。
|
||||
const emptyWindow = rows.length === 0;
|
||||
const sourceLastUpdate = emptyWindow ? await loadSourceLastUpdate() : '';
|
||||
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
||||
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
||||
const sourceAgeMinutes = sourceLastUpdate ? diffMinutes(sourceLastUpdate, generatedAt) : null;
|
||||
const sourceStale = sourceAgeMinutes !== null && sourceAgeMinutes > SOURCE_STALE_MINUTES;
|
||||
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
||||
let hint = '';
|
||||
if (emptyWindow) {
|
||||
const lastText = sourceLastUpdate || '';
|
||||
const hours = sourceAgeMinutes === null ? 0 : Math.round((sourceAgeMinutes / 60) * 10) / 10;
|
||||
if (sourceStale) {
|
||||
hint = `${batch.shipDate} 的发货记录在物流镜像表里一条都没有 —— 该表最后更新 ${lastText}(已 ${hours} 小时未同步)。`
|
||||
+ ' 上面的「0 单 / 0 个包裹」是数据没进库,不代表当天没发货。'
|
||||
+ ' 请先到聚水潭核对当天发货量,等上游同步后再刷新本页。';
|
||||
} else if (lastText) {
|
||||
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录(该表最后更新 ${lastText},数据源本身正常)。`
|
||||
+ ' 若当天确实有发货,说明上游漏同步了这一天,请到聚水潭核对;若当天本就停发,这个 0 才是真的。';
|
||||
} else {
|
||||
hint = `${batch.shipDate} 在物流镜像表里 0 条发货记录,且取不到该表最后更新时间 ——`
|
||||
+ ' 分不清是「当天没发货」还是「数据没同步」,请到聚水潭核对后再看本页。';
|
||||
}
|
||||
} else if (coversDeadline === false) {
|
||||
if (coversDeadline === false) {
|
||||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
||||
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
||||
} else if (stale) {
|
||||
@ -613,7 +546,7 @@ async function getLogisticsExceptions(options = {}) {
|
||||
interceptPackages: skippedByIntercept.packages,
|
||||
interceptOrders: skippedByIntercept.orders.size,
|
||||
interceptReasons: skippedByIntercept.reasons,
|
||||
// 转仓代发(转x00005)—— 整单不计:既不算异常,也不进原仓发货分母
|
||||
// 转仓代发(转x00005)—— 同上,只摘异常、保留发货分母
|
||||
transferPackages: skippedByTransfer.packages,
|
||||
transferOrders: skippedByTransfer.orders.size,
|
||||
transferReasons: skippedByTransfer.reasons,
|
||||
@ -627,12 +560,6 @@ async function getLogisticsExceptions(options = {}) {
|
||||
ageMinutes,
|
||||
stale,
|
||||
coversDeadline,
|
||||
// 本批窗口 0 行:0 单含义不明,必须配合下面两个字段读
|
||||
emptyWindow,
|
||||
// 表级最后灌库时刻(只有 emptyWindow 时才探测,否则为空串)
|
||||
sourceLastUpdate,
|
||||
sourceAgeMinutes,
|
||||
sourceStale,
|
||||
hint,
|
||||
},
|
||||
generatedAt,
|
||||
|
||||
@ -1,373 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「仓储中心发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-27):
|
||||
* 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」
|
||||
* ⟹ **一天两推**,各自绑一个核验轮次:
|
||||
* NOON 13:00 核验(窗口上界 07:00 前审核)→ 默认 13:05 推
|
||||
* AFTERNOON 18:00 核验(窗口上界 16:00 前审核)→ 默认 18:05 推
|
||||
* ⚠️ 默认给到「核验时刻 + 5 分钟」而不是整点:状态镜像 `query_time` 要略晚于核验时刻,
|
||||
* 否则 `freshness.coversDeadline=false`(数据没覆盖本轮核验点),推出去的超时数必然偏大。
|
||||
* 业务要改时刻在页面上改,不必重启后端。
|
||||
*
|
||||
* 通道 = 钉钉群自定义机器人 webhook(与「物流转运异常」同一套,只发 text、不发附件)。
|
||||
* 配置分两层存(与 yydNotificationCenterSettings 同一套做法):
|
||||
* · 普通字段 → 本地库 `system_config`,key = warehouse_ship_timeliness_notify_config
|
||||
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
|
||||
*
|
||||
* 状态另存一份(key = warehouse_ship_timeliness_notify_state),按 `<日期>|<轮次键>` 去重 ——
|
||||
* 服务重启、每分钟 tick 重复触发都不会重复轰炸;页面「立即推送」才强制重发。
|
||||
*/
|
||||
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getResilientSecret,
|
||||
saveResilientSecret,
|
||||
} = require('./resilientSecretStore');
|
||||
|
||||
const CONFIG_KEY = 'warehouse_ship_timeliness_notify_config';
|
||||
const SECRET_KEY = 'warehouse_ship_timeliness_notify_webhook_secret';
|
||||
const STATE_KEY = 'warehouse_ship_timeliness_notify_state';
|
||||
|
||||
// 两个轮次的默认推送时刻(核验 13:00 / 18:00 + 5 分钟,见文件头说明)
|
||||
const DEFAULT_SEND_TIME_NOON = '13:05';
|
||||
const DEFAULT_SEND_TIME_AFTERNOON = '18:05';
|
||||
|
||||
// 明细条数:text 上限 20000 字节,30 条中文明细约 9~12KB,留足余量
|
||||
const DEFAULT_MAX_DETAIL_ROWS = 30;
|
||||
const MAX_DETAIL_ROWS_LIMIT = 50;
|
||||
|
||||
// 页面地址(消息末尾「查看全部」用)。生产入口带 8443,别写成裸域名
|
||||
const DEFAULT_PAGE_URL = process.env.WST_PAGE_URL
|
||||
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness';
|
||||
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
// 状态里最多保留多少条「日期|轮次」记录(一天两条,30 天足够,避免 system_config 无限长)
|
||||
const MAX_STATE_ENTRIES = 60;
|
||||
|
||||
let cachedConfig = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
function normalizeText(value, maxLength = 512) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback = {}) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; }
|
||||
}
|
||||
|
||||
function maskSecret(value) {
|
||||
const text = normalizeText(value);
|
||||
if (!text) return '';
|
||||
if (text.length <= 8) return `${text.slice(0, 2)}******`;
|
||||
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** HH:mm(00:00 ~ 23:59);非法输入退回默认值,不抛错(避免把定时任务配挂) */
|
||||
function normalizeSendTime(value, fallback = DEFAULT_SEND_TIME_NOON) {
|
||||
const text = normalizeText(value, 5);
|
||||
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */
|
||||
function normalizeMaxDetailRows(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS;
|
||||
return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num)));
|
||||
}
|
||||
|
||||
function normalizePageUrl(value) {
|
||||
const text = normalizeText(value, 1024);
|
||||
if (!text) return DEFAULT_PAGE_URL;
|
||||
try {
|
||||
const url = new URL(text);
|
||||
if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL;
|
||||
return url.toString();
|
||||
} catch (_error) {
|
||||
return DEFAULT_PAGE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function upsertConfigRow(value) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[CONFIG_KEY]
|
||||
);
|
||||
const text = JSON.stringify(value);
|
||||
if (rows[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[text, 'json', CONFIG_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: CONFIG_KEY,
|
||||
config_value: text,
|
||||
config_type: 'json',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readConfigRow() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[CONFIG_KEY]
|
||||
);
|
||||
return safeJsonParse(rows[0]?.configValue, {});
|
||||
} catch (error) {
|
||||
console.warn('[WstNotify] 读取推送配置失败:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
|
||||
async function getShipTimelinessNotifyRuntimeConfig({ forceRefresh = false } = {}) {
|
||||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig };
|
||||
|
||||
const stored = await readConfigRow();
|
||||
const secret = await getResilientSecret(SECRET_KEY, {
|
||||
legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)],
|
||||
fallbackValues: [normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
|
||||
});
|
||||
|
||||
cachedConfig = {
|
||||
enabled: normalizeBoolean(stored.enabled, false),
|
||||
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|
||||
|| normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK, 1024),
|
||||
webhookSecret: secret.value,
|
||||
webhookSecretSource: secret.source,
|
||||
// 两轮各自的推送时刻
|
||||
noonSendTime: normalizeSendTime(
|
||||
stored.noonSendTime || stored.noon_send_time,
|
||||
DEFAULT_SEND_TIME_NOON
|
||||
),
|
||||
afternoonSendTime: normalizeSendTime(
|
||||
stored.afternoonSendTime || stored.afternoon_send_time,
|
||||
DEFAULT_SEND_TIME_AFTERNOON
|
||||
),
|
||||
maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows),
|
||||
atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false),
|
||||
pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url),
|
||||
source: Object.keys(stored).length > 0 ? 'database' : 'default',
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
|
||||
/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/
|
||||
function missingFields(config = {}) {
|
||||
const missing = [];
|
||||
if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址');
|
||||
return missing;
|
||||
}
|
||||
|
||||
function isShipTimelinessNotifyReady(config = {}) {
|
||||
return missingFields(config).length === 0;
|
||||
}
|
||||
|
||||
function toAdminConfig(config = {}) {
|
||||
return {
|
||||
enabled: config.enabled === true,
|
||||
webhookUrl: config.webhookUrl || '',
|
||||
webhookSecretConfigured: Boolean(config.webhookSecret),
|
||||
webhookSecretPreview: maskSecret(config.webhookSecret),
|
||||
webhookSecretSource: config.webhookSecretSource || 'unconfigured',
|
||||
noonSendTime: normalizeSendTime(config.noonSendTime, DEFAULT_SEND_TIME_NOON),
|
||||
afternoonSendTime: normalizeSendTime(config.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON),
|
||||
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
|
||||
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
|
||||
atAll: config.atAll === true,
|
||||
pageUrl: normalizePageUrl(config.pageUrl),
|
||||
ready: isShipTimelinessNotifyReady(config),
|
||||
missingFields: missingFields(config),
|
||||
source: config.source || 'default',
|
||||
};
|
||||
}
|
||||
|
||||
async function getShipTimelinessNotifyAdminConfig() {
|
||||
const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
return toAdminConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
|
||||
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
|
||||
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
|
||||
*/
|
||||
async function saveShipTimelinessNotifyConfig(input = {}) {
|
||||
const current = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true });
|
||||
const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512);
|
||||
const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true;
|
||||
|
||||
const config = {
|
||||
enabled: normalizeBoolean(input.enabled, current.enabled),
|
||||
webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl,
|
||||
webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret),
|
||||
noonSendTime: input.noonSendTime === undefined && input.noon_send_time === undefined
|
||||
? normalizeSendTime(current.noonSendTime, DEFAULT_SEND_TIME_NOON)
|
||||
: normalizeSendTime(input.noonSendTime ?? input.noon_send_time, DEFAULT_SEND_TIME_NOON),
|
||||
afternoonSendTime: input.afternoonSendTime === undefined && input.afternoon_send_time === undefined
|
||||
? normalizeSendTime(current.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON)
|
||||
: normalizeSendTime(input.afternoonSendTime ?? input.afternoon_send_time, DEFAULT_SEND_TIME_AFTERNOON),
|
||||
maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined
|
||||
? normalizeMaxDetailRows(current.maxDetailRows)
|
||||
: normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows),
|
||||
atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true),
|
||||
pageUrl: input.pageUrl === undefined && input.page_url === undefined
|
||||
? normalizePageUrl(current.pageUrl)
|
||||
: normalizePageUrl(input.pageUrl ?? input.page_url),
|
||||
};
|
||||
|
||||
if (config.enabled) {
|
||||
const missing = missingFields(config);
|
||||
if (missing.length) {
|
||||
throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (config.webhookUrl) {
|
||||
// 这里只做「像不像」的浅校验;真正的连通性由「测试通道」按钮验证
|
||||
const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender');
|
||||
try {
|
||||
config.webhookUrl = normalizeWebhookUrl(config.webhookUrl);
|
||||
} catch (error) {
|
||||
throw Object.assign(new Error(error.message), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret);
|
||||
await upsertConfigRow({
|
||||
enabled: config.enabled,
|
||||
webhookUrl: config.webhookUrl,
|
||||
noonSendTime: config.noonSendTime,
|
||||
afternoonSendTime: config.afternoonSendTime,
|
||||
maxDetailRows: config.maxDetailRows,
|
||||
atAll: config.atAll,
|
||||
pageUrl: config.pageUrl,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
cachedConfig = {
|
||||
...config,
|
||||
webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')),
|
||||
source: 'database',
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return toAdminConfig(cachedConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送状态。
|
||||
* 结构(version 1):
|
||||
* {
|
||||
* version: 1,
|
||||
* sent: { '2026-10-27|NOON': { sentAt, rowCount, listedCount, bytes, deadline } , ... },
|
||||
* lastSentAt, lastStatus, lastError, lastFailedAt, lastRoundLabel
|
||||
* }
|
||||
* sent 只保留最近 MAX_STATE_ENTRIES 条(一天两轮,30 天足够)。
|
||||
*/
|
||||
async function loadShipTimelinessNotifyState() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[STATE_KEY]
|
||||
);
|
||||
const state = safeJsonParse(rows[0]?.configValue, {});
|
||||
if (!state || typeof state !== 'object') return {};
|
||||
return { ...state, sent: (state.sent && typeof state.sent === 'object') ? state.sent : {} };
|
||||
} catch (error) {
|
||||
console.warn('[WstNotify] 读取推送状态失败:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 去重键:`<北京日期>|<轮次键>` —— 一天两轮各自独立 */
|
||||
function buildPushSlot(dateKey, roundKey) {
|
||||
const date = normalizeText(dateKey, 10);
|
||||
const round = normalizeText(roundKey, 20).toUpperCase();
|
||||
if (!date || !round) return '';
|
||||
return `${date}|${round}`;
|
||||
}
|
||||
|
||||
async function saveShipTimelinessNotifyState(next = {}) {
|
||||
const value = JSON.stringify(next && typeof next === 'object' ? next : {});
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[STATE_KEY]
|
||||
);
|
||||
if (rows[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[value, 'json', STATE_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: STATE_KEY,
|
||||
config_value: value,
|
||||
config_type: 'json',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WstNotify] 保存推送状态失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 sent 裁剪到 MAX_STATE_ENTRIES 条,按 slot 日期倒序保留(新日期优先) */
|
||||
function trimSentMap(sent = {}) {
|
||||
const entries = Object.entries(sent || {});
|
||||
if (entries.length <= MAX_STATE_ENTRIES) return sent;
|
||||
entries.sort((a, b) => {
|
||||
const da = String(a[1]?.sentAt || '');
|
||||
const db = String(b[1]?.sentAt || '');
|
||||
return db.localeCompare(da);
|
||||
});
|
||||
return Object.fromEntries(entries.slice(0, MAX_STATE_ENTRIES));
|
||||
}
|
||||
|
||||
function clearShipTimelinessNotifyConfigCache() {
|
||||
cachedConfig = null;
|
||||
cachedAt = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_KEY,
|
||||
DEFAULT_MAX_DETAIL_ROWS,
|
||||
DEFAULT_PAGE_URL,
|
||||
DEFAULT_SEND_TIME_AFTERNOON,
|
||||
DEFAULT_SEND_TIME_NOON,
|
||||
MAX_DETAIL_ROWS_LIMIT,
|
||||
MAX_STATE_ENTRIES,
|
||||
SECRET_KEY,
|
||||
STATE_KEY,
|
||||
buildPushSlot,
|
||||
clearShipTimelinessNotifyConfigCache,
|
||||
getShipTimelinessNotifyAdminConfig,
|
||||
getShipTimelinessNotifyRuntimeConfig,
|
||||
isShipTimelinessNotifyReady,
|
||||
loadShipTimelinessNotifyState,
|
||||
maskSecret,
|
||||
missingFields,
|
||||
normalizeMaxDetailRows,
|
||||
normalizePageUrl,
|
||||
normalizeSendTime,
|
||||
saveShipTimelinessNotifyConfig,
|
||||
saveShipTimelinessNotifyState,
|
||||
toAdminConfig,
|
||||
trimSentMap,
|
||||
};
|
||||
@ -1,10 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心发货时效(2026-10-20 新增)
|
||||
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-20):
|
||||
* MKH-BOM 列表左侧加一栏「仓储中心发货时效」,下列 11 个仓:
|
||||
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
||||
@ -32,10 +32,7 @@
|
||||
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
||||
* 🔴 2026-10-28 现状:候选池取订单两表的 `send_date`,起算点取状态表的
|
||||
* `last_oa_confirm_time`(审核时间),**下面这段「创建时间 = 出库单 created」
|
||||
* 是老口径,仅作历史记录,不要再照着实现**。
|
||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)(已废弃)
|
||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
|
||||
* ⚠️ 不是 `order_date`(平台下单时间);
|
||||
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
||||
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
||||
@ -265,48 +262,6 @@ const OUTBOUND_TABLES = Object.freeze({
|
||||
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
|
||||
const ORDER_STATE_TABLE = 'jst_order_express_details';
|
||||
|
||||
// ── 🔥 2026-10-28 数据源切换:主数据源改成「订单 + 订单明细」────────────────
|
||||
// 业务 yanhui 2026-10-28 明确指出:「核验抓取的销售订单表是错误的,应该抓
|
||||
// `jst_Orders`(订单)+ `jst_OrderItem`(订单明细),o_id → o_id;审核时间就是
|
||||
// jst_Orders 里的创建时间」。
|
||||
//
|
||||
// 为什么原来那张状态镜像表不能用(实测证据):
|
||||
// ① `send_time` 会**漏回写**:近 7 天「状态表说发货中+未发」的 4157 单里,
|
||||
// 订单表已有 `send_date` 的 **563 单**、status 已是 Sent 的 492 单 ——
|
||||
// 明明发了却还算超时;2026-10-28 06h/07h 那批大批量刷新后集中爆发。
|
||||
// ② 状态表对**被合并 / 拆单父单**不回写(已有 89 + 755 单),必须回订单表看 status。
|
||||
// ③ 状态表会**整批停更**:2026-10-27 11:54:59 之后 6.8 小时零写入,
|
||||
// 18:00 轮推出去 34 单里混着已发货的(业务当场发现)。
|
||||
// ④ 两张表发货时间**其实同源**:状态表 send_time 与订单表 send_date
|
||||
// 27387 单逐条比对**零偏差** ⟹ 订单表才是权威,状态表只是它的滞后副本。
|
||||
//
|
||||
// ⟹ 主查询直接打订单两表(店铺互斥必须都读),**不再依赖状态表**。
|
||||
// 仓库归属用订单表的 `wms_co_id`(不是状态表的仓名文本),更准。
|
||||
// 状态表只剩「售后已作废」一个判据,实测在「发货中+未发」里只影响 4 单,可放弃。
|
||||
const ORDER_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||
|
||||
// 订单表 `status` 的取值(聚水潭实时状态,不是下单快照 —— 这一点
|
||||
// `jst_Orders.status` 与代码历史上「status 不可信」的结论不同,见下面 CREATED 段说明):
|
||||
// Delivering 发货中 —— **唯一该考核仓库的状态**
|
||||
// WaitConfirm 待审核 —— 仓库还没接到单,单列「待审核」不计超时
|
||||
// Question 疑问/异常 —— 单列「异常待处理」不计超时
|
||||
// Sent 已发货 → 剔除
|
||||
// Cancelled 已取消(业务叫「作废」)→ 剔除
|
||||
// Split / Merged 拆单父单 / 被合并单 → 剔除(自己永不发货)
|
||||
// WaitOuterSent 等供销商|外仓发货 → 剔除(不是本仓发)
|
||||
// 近 30 天分布:Sent 132341 / Cancelled 66643 / Split 8251 / Question 2057 /
|
||||
// Merged 1854 / Delivering 1446 / WaitConfirm 190 / WaitOuterSent 45
|
||||
const ORDER_PENDING_STATUS = 'Delivering';
|
||||
const ORDER_UNAUDITED_STATUS = 'WaitConfirm';
|
||||
const ORDER_QUESTION_STATUS = 'Question';
|
||||
const ORDER_SHIPPED_STATUS = 'Sent';
|
||||
// 主查询只捞这三种(其余状态一律不进候选,SQL 侧就过滤掉,不传回 Node)
|
||||
const ORDER_CANDIDATE_STATUSES = Object.freeze([
|
||||
ORDER_PENDING_STATUS,
|
||||
ORDER_UNAUDITED_STATUS,
|
||||
ORDER_QUESTION_STATUS,
|
||||
]);
|
||||
|
||||
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
|
||||
const SHIPPING_STATUS = '发货中';
|
||||
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
|
||||
@ -321,26 +276,24 @@ const ABNORMAL_STATUS = '异常';
|
||||
// 漏掉这个排除,整个页面会被作废单淹没。
|
||||
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
||||
|
||||
// ── 起算点:聚水潭「审核时间」`last_oa_confirm_time` ────────────────────────
|
||||
// 🔥 2026-10-28 **下午二次纠偏**(业务 yanhui 拿 9837824 打回来):
|
||||
// 起算点是**审核时间**,不是订单创建时间。证据:
|
||||
// 9837824 created 10-19 23:44:22 → last_oa_confirm_time 10-28 09:58:42,
|
||||
// 积压 8 天半;仓库 09:58 审核、10:09:55 快递员就上门取件。
|
||||
// 按 created 算「超时 205 小时」= 把客服审核积压算到仓库头上。
|
||||
// 量化(10-28 13:00 轮 599 单):376 单是当天 08:10~10:30 才批量审核的,
|
||||
// 按审核时间分轮后 13:00 轮只剩 247(再剔除漏回写的已发单 → 138)。
|
||||
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
|
||||
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
|
||||
//
|
||||
// ⚠️ 上午那版注释里「created 100% 早于 last_oa_confirm_time,正常差 7~40 分钟,
|
||||
// 业务要求按创建时间算」已被推翻 —— 那句话只统计了当期正常单,
|
||||
// 长尾积压单能差 8~11 天。**别再把起算点改回 created。**
|
||||
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补:
|
||||
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。
|
||||
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51),
|
||||
// 是**同步落库时间**,用了会把起算点整体推后几小时。
|
||||
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
|
||||
// 要换掉。
|
||||
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
|
||||
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
|
||||
// 这才是聚水潭订单列表里「创建时间」那一列。
|
||||
//
|
||||
// 审核时间只存在于状态镜像表 `jst_order_express_details`(订单两表没有这个字段),
|
||||
// 由 service 层按 `inner_order_id IN (...)` 批量取;取不到时回退 created 并标 `*`。
|
||||
// 用 last 而不是 first:业务认为改单重审后重新进作业流,从最后一次审核计时
|
||||
// (代价是重复审核会冲掉已累计超时,业务已知悉)。
|
||||
// 性能(2026-10-20 实测 9,676 个候选):
|
||||
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。
|
||||
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。
|
||||
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
||||
// ⚠️ 2026-10-28 起主链路已不用(created 直接从主查询取),保留给历史脚本/测试引用。
|
||||
const CREATED_BACKFILL_BATCH = 200;
|
||||
|
||||
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
|
||||
@ -378,64 +331,6 @@ const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
||||
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
||||
const AFTERSALE_VOID_STATUS = '已作废';
|
||||
|
||||
/**
|
||||
* 状态镜像表 `jst_order_express_details.order_status_name` 的**终态**。
|
||||
*
|
||||
* 🔥 2026-10-29 血的教训(业务第三次报「作废单/已发单还在考核里」):
|
||||
* 订单两表**会各自停更**(当天 qmcust 停在 07:25、jst_Orders 停在 12:45),
|
||||
* 停更期间在聚水潭做的「取消 / 发货」动作,订单表里根本看不到 ——
|
||||
* 订单 status 仍是 `Delivering`、`send_date` 仍是空,于是被当成真超时。
|
||||
* 状态镜像表虽然也会停(当天 11:52),但**比订单表新鲜 4 小时**,
|
||||
* 而且 `order_status_name` 是聚水潭订单状态的直接映射。
|
||||
*
|
||||
* 实测(2026-10-29 13:00 轮 159 单):
|
||||
* · `order_status_name='已取消'` 11 单 —— 订单表的 `labels` 里**没有**「发货前售后」
|
||||
* 这个关键词(`presaleVoid` 判据完全失效),状态表的 `order_labels` 才有,且带
|
||||
* 「已打印被取消」。业务就是拿这 11 单说「作废的也在考核之内」。
|
||||
* · `order_status_name='已发货'` 0 单 —— 机制上必须有,否则停更期发的货永远判不出。
|
||||
*
|
||||
* ⚠️ **`aftersale_status_name='已作废'` 不是作废单**!那是「售后单被撤销」,
|
||||
* 订单照常发货、**仍然要考核**(近 2 天 1387 单都是这种)。别把两个混为一谈,
|
||||
* 能判「这单不用发了」的只有 `order_status_name='已取消'`。
|
||||
*/
|
||||
const STATE_ORDER_VOID_STATUS = '已取消';
|
||||
const STATE_ORDER_SHIPPED_STATUS = '已发货';
|
||||
|
||||
/**
|
||||
* 销售出库单 `status` 里**等于单据作废**的值。
|
||||
*
|
||||
* 🔥 2026-10-29 新增(业务点名 9992097 等 9 单:「在聚水潭的销售出库单里找一下这个作废单据」)。
|
||||
*
|
||||
* 聚水潭的作废会落在**出库单**上:`jst_salesOutboundInfo.status` / `jst_qmcust_salesoutbound.status`
|
||||
* 取值只有四种(近 2 天实测):`Confirmed` / `WaitConfirm` / `Delete` / `OuterConfirming`,
|
||||
* 其中 `Delete` 就是单据作废。
|
||||
*
|
||||
* 为什么必须加这一条判据(状态表 `order_status_name='已取消'` 不够):
|
||||
* · 出库单 `Delete` 的近 7 天样本里,**55 单订单表仍是 `Delivering`、36 单是 `Question`**,
|
||||
* 这些单状态镜像表未必抓得到(它一天只抓几次),只能靠出库单判;
|
||||
* · 出库单是仓库侧最贴身的单据,作废动作一定先落在这里。
|
||||
*
|
||||
* ⚠️ **不能只看「有 Delete 就算作废」**:一个订单可能有多张出库单(拆包 / 重开),
|
||||
* 删掉旧的那张、新的还在 `WaitConfirm` —— 这种单照常要发。
|
||||
* ⟹ 判据是「**该订单没有任何非 Delete 的出库单**」,见 service 里 ioAllDeleted 的说明。
|
||||
*
|
||||
* ⚠️ 也**不能**用「没有出库单」当判据:近 3 天未发单里 **82%(1467/1790)本来就没有出库单**
|
||||
* (订单还没推下游),拿它判作废会把绝大多数正常待发单全放跑。
|
||||
*/
|
||||
const IO_VOID_STATUS = 'Delete';
|
||||
|
||||
/**
|
||||
* 状态镜像表 `logistic_status_name` 里**等于已发**的值。
|
||||
*
|
||||
* `拒收` 也算已发 —— 快递拿走过了,客户拒收是后续环节,仓库的发货动作已完成。
|
||||
* ⚠️ **`无物流单` 不算**(39/159 单是这种:单子还没录快递单,可能自提/待定),
|
||||
* `未揽收` 也不算(只是打了单,快递还没来取)。
|
||||
* ⚠️ 别拿 `logistic_time` 当发货时间:它多数是「快递员将上门取件」的预告。
|
||||
*/
|
||||
const STATE_LOGISTIC_SHIPPED_STATUSES = Object.freeze([
|
||||
'已揽收', '运输中', '派送中', '已签收', '拒收',
|
||||
]);
|
||||
|
||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||
@ -544,29 +439,12 @@ async function loadCenterWarehouseMap(force = false) {
|
||||
warehouseCache = {
|
||||
at: Date.now(),
|
||||
rows: list,
|
||||
// 全量「wms_co_id → 仓名」映射(**含归不到主仓的仓**)。
|
||||
// 发货时效主查询不再 LEFT JOIN 仓表(实测 JOIN 会让执行计划从 28s 恶化到 61~105s),
|
||||
// 改为在主查询里取回 wms_co_id、在 Node 侧用这份缓存换名字。
|
||||
allNames: rows.map((r) => ({ wmsCoId: Number(r.wms_co_id), name: String(r.name || '') })),
|
||||
unmatched,
|
||||
unmatchedCount: unmatched.length,
|
||||
excluded,
|
||||
};
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 全量「wms_co_id → 仓名」映射(含归不到主仓的仓),供 service 侧做仓名还原 */
|
||||
async function loadWarehouseNameMap() {
|
||||
if (Date.now() - warehouseCache.at >= WAREHOUSE_CACHE_TTL_MS || !warehouseCache.rows.length) {
|
||||
await loadCenterWarehouseMap();
|
||||
}
|
||||
const map = new Map();
|
||||
for (const r of warehouseCache.allNames || []) {
|
||||
if (Number.isFinite(r.wmsCoId)) map.set(r.wmsCoId, r.name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function clearWarehouseCache() {
|
||||
warehouseCache = { at: 0, rows: [] };
|
||||
}
|
||||
@ -679,7 +557,6 @@ module.exports = {
|
||||
centerWindowEnd,
|
||||
nextRoundDeadline,
|
||||
loadCenterWarehouseMap,
|
||||
loadWarehouseNameMap,
|
||||
clearWarehouseCache,
|
||||
getWarehouseCache: () => warehouseCache,
|
||||
resolveActiveRound,
|
||||
@ -692,13 +569,6 @@ module.exports = {
|
||||
NOT_SPLIT_MERGED_SQL,
|
||||
OUTBOUND_TABLES,
|
||||
ORDER_STATE_TABLE,
|
||||
// 2026-10-28 起主数据源 = 订单两表(状态表退居兜底)
|
||||
ORDER_SOURCE_TABLES,
|
||||
ORDER_PENDING_STATUS,
|
||||
ORDER_UNAUDITED_STATUS,
|
||||
ORDER_QUESTION_STATUS,
|
||||
ORDER_SHIPPED_STATUS,
|
||||
ORDER_CANDIDATE_STATUSES,
|
||||
SHIPPING_STATUS,
|
||||
UNAUDITED_STATUS,
|
||||
ABNORMAL_STATUS,
|
||||
@ -709,10 +579,6 @@ module.exports = {
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
STATE_ORDER_VOID_STATUS,
|
||||
STATE_ORDER_SHIPPED_STATUS,
|
||||
STATE_LOGISTIC_SHIPPED_STATUSES,
|
||||
IO_VOID_STATUS,
|
||||
SHIP_TIMELINESS_HOURS,
|
||||
toNumber,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -154,10 +154,6 @@ function stubDb(rows, options = {}) {
|
||||
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
||||
return options.interceptRows || [];
|
||||
}
|
||||
// 窗口 0 行时的「表级最后灌库时刻」探测
|
||||
if (/MAX\(\s*e\.query_time\s*\)/iu.test(String(sql))) {
|
||||
return options.sourceMaxQueryTime === undefined ? [] : [{ max_q: options.sourceMaxQueryTime }];
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
require.cache[dbPath] = {
|
||||
@ -559,9 +555,9 @@ test('转仓标签识别:转x00005(含大小写/空格)都认,转空包
|
||||
assert.equal(registry.matchTransferWarehouse(null), null);
|
||||
});
|
||||
|
||||
test('转仓代发整单不计:不算异常,也不进原仓发货分母', async () => {
|
||||
test('转仓代发不算异常,但仍计入发货分母', async () => {
|
||||
const mixed = [
|
||||
// 30:转x00005 · 未揽收 → 崔桥下单、曹县代发,货不是从原仓走的,整单不计
|
||||
// 30:转x00005 · 未揽收 → 崔桥下单、曹县代发,本仓永远「未揽收」,必须不报异常
|
||||
mk({ inner_order_id: 30, so_id: 'SO-30', logistic_status_name: '未揽收', logistic_time: null,
|
||||
logistic_company_name: '曹县韵达小件', order_labels: '["京喜","转x00005"]' }),
|
||||
// 31:同样「未揽收」但没转仓标签 → 必须留下
|
||||
@ -575,13 +571,7 @@ test('转仓代发整单不计:不算异常,也不进原仓发货分母', as
|
||||
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');
|
||||
assert.equal(data.summary.packageCount, 2, '转仓单仍计入发货分母(货确实发了)');
|
||||
}, mixed);
|
||||
});
|
||||
|
||||
@ -676,42 +666,3 @@ test('售后表查询失败时降级:不剔单、不炸页面', async () => {
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
}
|
||||
});
|
||||
|
||||
// ── ⑧ 窗口 0 行:0 单不能被当成「当天没发货 / 全部正常」(2026-11-02)──────
|
||||
// 起因:11-02 09:00 那批推出「已发货 0 单 / 0 个包裹,异常 0 个包裹」,
|
||||
// 实况是镜像表 11-01 07:55 之后就没再灌库,11-01 的 12 仓发货记录一条都没进库。
|
||||
test('窗口 0 行 + 数据源停更 → emptyWindow/sourceStale,hint 明说「数据没进库」', async () => {
|
||||
const nov2 = new Date(2026, 10, 2, 10, 0, 0); // 2026-11-02 10:00(本机时区)
|
||||
await withStub(async (svc, calls) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||
assert.equal(data.summary.shippedOrders, 0);
|
||||
assert.equal(data.freshness.emptyWindow, true);
|
||||
assert.equal(data.freshness.sourceLastUpdate, '2026-11-01 07:55:04');
|
||||
assert.ok(data.freshness.sourceAgeMinutes > 120, `应算出停更小时数,实际 ${data.freshness.sourceAgeMinutes}`);
|
||||
assert.equal(data.freshness.sourceStale, true);
|
||||
assert.match(data.freshness.hint, /一条都没有/);
|
||||
assert.match(data.freshness.hint, /不代表当天没发货/);
|
||||
// 只在空窗口才付那次全表 MAX(query_time)(实测 13.5s)
|
||||
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 1);
|
||||
}, [], { sourceMaxQueryTime: '2026-11-01 07:55:04' });
|
||||
});
|
||||
|
||||
test('窗口 0 行但数据源是新的 → 仍然提示「0 条记录」,不能静默全绿', async () => {
|
||||
const nov2 = new Date(2026, 10, 2, 10, 0, 0);
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||
assert.equal(data.freshness.emptyWindow, true);
|
||||
assert.equal(data.freshness.sourceStale, false, '表是新的,不该报停更');
|
||||
assert.match(data.freshness.hint, /0 条发货记录/, '真 0 单也要说清是「库里没有记录」');
|
||||
assert.match(data.freshness.hint, /数据源本身正常/);
|
||||
}, [], { sourceMaxQueryTime: '2026-11-02 09:58:00' });
|
||||
});
|
||||
|
||||
test('窗口有行时不做全表探测(省掉 13.5s 的表级 MAX)', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
assert.equal(data.freshness.emptyWindow, false);
|
||||
assert.equal(data.freshness.sourceLastUpdate, '');
|
||||
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -495,50 +495,3 @@ test('发文本:超长正文自动截断后仍会发出去(不是直接抛
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── ⑤ 0 单不能用「全部正常」糊过去(2026-11-02)──────────────────────────
|
||||
// 实况:11-02 09:00 那批推的是「0 单 / 0 个包裹,异常 0 个包裹」,业务以为真没发货,
|
||||
// 其实是物流镜像表 11-01 07:55 后就没同步。群里绝不能出现「全部正常」这种假绿。
|
||||
test('数据源停更导致 0 单 → 正文首部醒目告警,且不出现「全部正常」', () => {
|
||||
const data = sampleData({
|
||||
rows: [],
|
||||
summary: {
|
||||
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||||
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||||
},
|
||||
exclusions: {},
|
||||
freshness: {
|
||||
emptyWindow: true,
|
||||
sourceLastUpdate: '2026-11-01 07:55:04',
|
||||
sourceAgeMinutes: 1565,
|
||||
sourceStale: true,
|
||||
hint: '11-01 的发货记录在物流镜像表里一条都没有',
|
||||
},
|
||||
});
|
||||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||
assert.match(built.text, /物流数据未同步/);
|
||||
assert.match(built.text, /镜像表最后更新 2026-11-01 07:55:04/);
|
||||
assert.match(built.text, /已 26\.1 小时未同步/);
|
||||
assert.match(built.text, /不代表当天没发货/);
|
||||
assert.ok(!built.text.includes('全部正常'), '数据没进库时不能说「全部正常」');
|
||||
// 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收
|
||||
assert.match(built.text, /【仓储物流转运异常】/);
|
||||
});
|
||||
|
||||
test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」,不说「全部正常」也不谎报停更', () => {
|
||||
const data = sampleData({
|
||||
rows: [],
|
||||
summary: {
|
||||
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||||
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||||
},
|
||||
exclusions: {},
|
||||
freshness: { emptyWindow: true, sourceLastUpdate: '2026-11-02 09:58:00', sourceAgeMinutes: 2, sourceStale: false },
|
||||
});
|
||||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||
assert.match(built.text, /在物流镜像表里 0 条发货记录/);
|
||||
assert.match(built.text, /数据源本身正常/);
|
||||
assert.match(built.text, /请到聚水潭核对/);
|
||||
assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更');
|
||||
assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」');
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,294 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「仓储中心 24 小时发货时效」两轮核验完成后推钉钉群 —— 正文组装与调度回归测试
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-27):「13 点核验 7 点前的超时单,核验完成后通知;
|
||||
* 18:00 核验的订单,核验完成后也通知。」⟹ 一天两推,各绑一个轮次。
|
||||
*
|
||||
* 这里只测五件容易悄悄坏掉的事:
|
||||
* ① 两个轮次各自的推送时刻能不能正确命中(含「配成同一时刻」的边界);
|
||||
* ② 群消息正文的数字与明细(业务照着这条消息追单,数字错了没人能发现);
|
||||
* ③ 0 单超时也要推(群里看到「本轮 0 单」= 任务没挂);
|
||||
* ④ 明细按仓保底 + 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
|
||||
* ⑤ 去重键必须「日期 + 轮次」,一天两轮互不干扰。
|
||||
*
|
||||
* ⚠️ 本文件不连数据库、不发网络请求,纯逻辑测试。
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const job = require('../src/jobs/warehouseShipTimelinessNotifyJob');
|
||||
const settings = require('../src/services/warehouseShipTimelinessNotifySettings');
|
||||
|
||||
function sampleData(overrides = {}) {
|
||||
return {
|
||||
round: { key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEnd: '2026-10-27 07:00:00' },
|
||||
deadline: '2026-10-27 13:00:00',
|
||||
date: '2026-10-27',
|
||||
window: { start: '2026-10-07 00:00:00', end: '2026-10-27 07:00:00', includeHistory: true, boundary: 'audit' },
|
||||
summary: {
|
||||
overdueUnshipped: 3,
|
||||
overdueCenterCount: 2,
|
||||
oldestDays: 4,
|
||||
heldCount: 5,
|
||||
voidCount: 12,
|
||||
carrierSkipped: 2,
|
||||
},
|
||||
centers: [
|
||||
{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 2, oldestDays: 4 },
|
||||
{ key: 'HL2', name: '横林二仓', overdueUnshipped: 1, oldestDays: 1 },
|
||||
{ key: 'SN', name: '睢宁仓', overdueUnshipped: 0, oldestDays: 0 },
|
||||
],
|
||||
orders: [
|
||||
{
|
||||
oId: '9855123',
|
||||
soId: '261021-301318827670255',
|
||||
shopName: '京东旗舰店',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
centerName: '崔桥工厂仓',
|
||||
logisticCompany: '武进韵达',
|
||||
auditTime: '2026-10-27 06:32:11',
|
||||
overdueMinutes: 388,
|
||||
},
|
||||
{
|
||||
oId: '9855200',
|
||||
soId: 'SO20261027001',
|
||||
shopName: '天猫店',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
centerName: '崔桥工厂仓',
|
||||
logisticCompany: '常州安能新',
|
||||
auditTime: '2026-10-26 09:05:00',
|
||||
overdueMinutes: 2875,
|
||||
},
|
||||
{
|
||||
oId: '9855300',
|
||||
soId: '',
|
||||
shopName: '抖音店',
|
||||
warehouseName: '简尚-横林二仓',
|
||||
centerName: '横林二仓',
|
||||
// 快递名为空 → 明细里要显示「—」,不能出现 undefined
|
||||
logisticCompany: '',
|
||||
auditTime: '',
|
||||
overdueMinutes: 120,
|
||||
},
|
||||
],
|
||||
freshness: { stateMaxQuery: '2026-10-27 13:04:00', coversDeadline: true, stale: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function config(overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test',
|
||||
webhookSecret: '',
|
||||
noonSendTime: '13:05',
|
||||
afternoonSendTime: '18:05',
|
||||
maxDetailRows: 30,
|
||||
atAll: false,
|
||||
pageUrl: 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造北京时间某个时刻(本机是东八区,直接 new Date(本地字段) 即可) */
|
||||
function bj(year, month, day, hour, minute) {
|
||||
return new Date(year, month - 1, day, hour, minute, 0, 0);
|
||||
}
|
||||
|
||||
// ── ① 两个轮次各自的推送时刻 ──────────────────────────────────────────────
|
||||
test('两个轮次按各自配置的时刻命中', () => {
|
||||
const cfg = config();
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON']);
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 18, 5)), ['AFTERNOON']);
|
||||
// 非命中时刻一律不推(免得每分钟都轰炸一次)
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 6)), []);
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 12, 59)), []);
|
||||
});
|
||||
|
||||
test('两个时刻配成同一个 HH:mm 时,两轮都要推(不能只推第一轮)', () => {
|
||||
const cfg = config({ noonSendTime: '13:05', afternoonSendTime: '13:05' });
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 5)), ['NOON', 'AFTERNOON']);
|
||||
});
|
||||
|
||||
test('补推窗口:错过时刻 30 分钟内启动仍会补,超窗口不补', () => {
|
||||
const cfg = config();
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: true }), ['NOON']);
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 40), { catchUp: true }), []);
|
||||
// 关掉补推时,错过就错过
|
||||
assert.deepEqual(job.resolveDueRounds(cfg, bj(2026, 10, 27, 13, 20), { catchUp: false }), []);
|
||||
});
|
||||
|
||||
// ── ② 正文内容 ────────────────────────────────────────────────────────────
|
||||
test('正文包含轮次、超时单数、涉及仓数、最久天数', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /【仓储中心发货时效】13:00 轮/);
|
||||
assert.match(built.text, /核验 07:00 前审核/);
|
||||
assert.match(built.text, /超时未发货 3 单,涉及 2 个仓,最久已等 4 天/);
|
||||
assert.equal(built.rowCount, 3);
|
||||
});
|
||||
|
||||
test('按仓块只列超时 > 0 的仓,并按单数降序', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /按仓(超时单):/);
|
||||
assert.match(built.text, /1\. 崔桥工厂仓 2 单(最久 4 天)/);
|
||||
assert.match(built.text, /2\. 横林二仓 1 单(最久 1 天)/);
|
||||
assert.doesNotMatch(built.text, /睢宁仓 0 单/);
|
||||
});
|
||||
|
||||
test('明细一行 = 仓|内部单号|线上单号|快递|审核 时间|已等', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /1\. 简尚-崔桥工厂仓|9855123|261021-301318827670255|武进韵达|审核 06:32|已等 6 小时 28 分/);
|
||||
// 无线上单号 → 「—」;无快递名 → 「—」;无创建时间 → 「无创建时间」;都不能出现 undefined
|
||||
assert.match(built.text, /3\. 简尚-横林二仓|9855300|—|—|审核 —|已等 2 小时/);
|
||||
assert.doesNotMatch(built.text, /undefined|NaN/);
|
||||
});
|
||||
|
||||
test('快递列紧跟线上单号(业务 2026-10-27 要求:群里直接看是哪家快递)', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
const line = built.text.split('\n').find((l) => l.startsWith('2. 简尚-崔桥工厂仓'));
|
||||
assert.ok(line, '明细第 2 行不见了');
|
||||
const parts = line.replace(/^\d+\.\s*/, '').split('|');
|
||||
assert.deepEqual(parts, ['简尚-崔桥工厂仓', '9855200', 'SO20261027001', '常州安能新', '审核 10-26 09:05', '已等 1 天 23 小时']);
|
||||
});
|
||||
|
||||
test('历史未发单的审核时间必须带 MM-DD,否则会被读成「今天 16:13」', () => {
|
||||
// 13:00 轮窗口上界 07:00,但口径「含历史未发」→ 明细里会出现昨天的单
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /2\. 简尚-崔桥工厂仓|9855200|SO20261027001|常州安能新|审核 10-26 09:05|已等 1 天 23 小时/);
|
||||
// 同一天的就只给 HH:mm,别啰嗦
|
||||
assert.match(built.text, /审核 06:32/);
|
||||
assert.doesNotMatch(built.text, /审核 10-27 06:32/);
|
||||
// 直接测函数本身的跨天判定
|
||||
assert.equal(job.auditWhen('2026-10-27 06:32:11', '2026-10-27'), '06:32');
|
||||
assert.equal(job.auditWhen('2026-10-26 16:13:00', '2026-10-27'), '10-26 16:13');
|
||||
assert.equal(job.auditWhen('', '2026-10-27'), '');
|
||||
});
|
||||
|
||||
test('不计考核 / 不考核快递 / 数据未覆盖核验点都要在正文里说明', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /另有 5 单不计考核/);
|
||||
assert.match(built.text, /已剔除 2 单不考核快递/);
|
||||
assert.doesNotMatch(built.text, /⚠️/);
|
||||
|
||||
const stale = job.buildNotifyText(sampleData({
|
||||
freshness: { stateMaxQuery: '2026-10-27 12:31:00', coversDeadline: false, stale: true },
|
||||
}), { maxDetailRows: 30 });
|
||||
assert.match(stale.text, /⚠️ 数据快照只到 12:31/);
|
||||
assert.match(stale.text, /尚未覆盖本轮核验时刻 13:00/);
|
||||
assert.match(stale.text, /超时单量偏大/);
|
||||
});
|
||||
|
||||
// ── ③ 0 单也要推 ──────────────────────────────────────────────────────────
|
||||
test('本轮 0 单超时:发「全部按时发出」,不带空明细块', () => {
|
||||
const built = job.buildNotifyText(sampleData({
|
||||
summary: { overdueUnshipped: 0, overdueCenterCount: 0, oldestDays: 0, heldCount: 0, voidCount: 3, carrierSkipped: 0 },
|
||||
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 0, oldestDays: 0 }],
|
||||
orders: [],
|
||||
}), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /超时未发货 0 单,涉及 0 个仓/);
|
||||
assert.match(built.text, /本轮没有超时未发货的订单,全部按时发出/);
|
||||
assert.doesNotMatch(built.text, /明细前/);
|
||||
assert.doesNotMatch(built.text, /查看全部/);
|
||||
assert.equal(built.rowCount, 0);
|
||||
assert.equal(built.listedCount, 0);
|
||||
});
|
||||
|
||||
// ── ④ 明细额度:按仓保底 + 超长精简 ───────────────────────────────────────
|
||||
test('明细额度按仓保底:一个仓吃不满额度,其它仓至少 1 条', () => {
|
||||
const many = [];
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
many.push({ oId: `A${i}`, soId: '', centerName: '崔桥工厂仓', warehouseName: '简尚-崔桥工厂仓', overdueMinutes: 600 + i });
|
||||
}
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
many.push({ oId: `B${i}`, soId: '', centerName: '横林二仓', warehouseName: '简尚-横林二仓', overdueMinutes: 300 });
|
||||
}
|
||||
const picked = job.pickDetailRows(many, 6);
|
||||
assert.equal(picked.length, 6);
|
||||
// 每仓保底 1 条,剩余额度按各仓条数比例分(40:2 ⇒ 5:1)
|
||||
assert.equal(picked.filter((row) => row.centerName === '横林二仓').length, 1);
|
||||
assert.equal(picked.filter((row) => row.centerName === '崔桥工厂仓').length, 5);
|
||||
// 组间按仓聚集:条数多的仓排前面,所以首行是崔桥
|
||||
assert.equal(picked[0].centerName, '崔桥工厂仓');
|
||||
});
|
||||
|
||||
test('明细条数 0 = 只发汇总不带明细', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
|
||||
assert.equal(built.listedCount, 0);
|
||||
assert.match(built.text, /明细共 3 条,消息里不放/);
|
||||
});
|
||||
|
||||
test('超长时从明细末尾往前砍,砍到能发为止', () => {
|
||||
// 故意把线上单号拉长(真实数据没见过,但业务改过列宽/加字段后有可能),
|
||||
// 保证 50 条明细 + 分仓块必然超过 20000 字节上限
|
||||
const rows = [];
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
rows.push({
|
||||
oId: `98${String(i).padStart(6, '0')}`,
|
||||
soId: `SO-2026-10-27-${String(i).padStart(6, '0')}-${'X'.repeat(360)}`,
|
||||
centerName: '崔桥工厂仓',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
auditTime: '2026-10-27 06:32:11',
|
||||
overdueMinutes: 400 + i,
|
||||
});
|
||||
}
|
||||
const built = job.buildNotifyText(sampleData({
|
||||
summary: { overdueUnshipped: 300, overdueCenterCount: 1, oldestDays: 4, heldCount: 0, voidCount: 0, carrierSkipped: 0 },
|
||||
centers: [{ key: 'CQ', name: '崔桥工厂仓', overdueUnshipped: 300, oldestDays: 4 }],
|
||||
orders: rows,
|
||||
}), { maxDetailRows: 50 });
|
||||
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, `bytes=${built.bytes} 超上限`);
|
||||
assert.equal(built.truncated, true);
|
||||
assert.ok(built.listedCount < 50);
|
||||
assert.match(built.text, /其余 \d+ 条见页面/);
|
||||
});
|
||||
|
||||
// ── ⑤ 去重键 ──────────────────────────────────────────────────────────────
|
||||
test('去重键是「日期|轮次」,两轮互不覆盖', () => {
|
||||
const noon = settings.buildPushSlot('2026-10-27', 'NOON');
|
||||
const afternoon = settings.buildPushSlot('2026-10-27', 'AFTERNOON');
|
||||
assert.equal(noon, '2026-10-27|NOON');
|
||||
assert.equal(afternoon, '2026-10-27|AFTERNOON');
|
||||
assert.notEqual(noon, afternoon);
|
||||
// 小写也归一
|
||||
assert.equal(settings.buildPushSlot('2026-10-27', 'noon'), '2026-10-27|NOON');
|
||||
assert.equal(settings.buildPushSlot('', 'NOON'), '');
|
||||
});
|
||||
|
||||
test('推送时刻归一化:非法值退回默认,不抛错', () => {
|
||||
assert.equal(settings.normalizeSendTime('13:05'), '13:05');
|
||||
assert.equal(settings.normalizeSendTime('8:5'), '13:05');
|
||||
assert.equal(settings.normalizeSendTime('25:00'), '13:05');
|
||||
assert.equal(settings.normalizeSendTime(''), '13:05');
|
||||
assert.equal(settings.normalizeSendTime('', '18:05'), '18:05');
|
||||
assert.equal(settings.DEFAULT_SEND_TIME_NOON, '13:05');
|
||||
assert.equal(settings.DEFAULT_SEND_TIME_AFTERNOON, '18:05');
|
||||
});
|
||||
|
||||
test('超长文案格式化:分钟 / 小时 / 跨天', () => {
|
||||
assert.equal(job.formatOverdue(30), '30 分钟');
|
||||
assert.equal(job.formatOverdue(120), '2 小时');
|
||||
assert.equal(job.formatOverdue(388), '6 小时 28 分');
|
||||
assert.equal(job.formatOverdue(1500), '1 天 1 小时');
|
||||
assert.equal(job.formatOverdue(0), '—');
|
||||
assert.equal(job.formatOverdue(null), '—');
|
||||
});
|
||||
|
||||
test('页面链接带上轮次参数,业务点进去看的就是这一轮', () => {
|
||||
const link = job.buildPageLink('https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness', 'AFTERNOON');
|
||||
assert.equal(link, 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness?round=AFTERNOON');
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30 });
|
||||
assert.match(built.text, /\?round=NOON/);
|
||||
});
|
||||
|
||||
test('轮次元信息:13:00 轮核验 07:00、18:00 轮核验 16:00', () => {
|
||||
const noon = job.resolveRoundMeta('NOON', bj(2026, 10, 27, 13, 5));
|
||||
const afternoon = job.resolveRoundMeta('AFTERNOON', bj(2026, 10, 27, 18, 5));
|
||||
assert.equal(noon.deadline, '2026-10-27 13:00:00');
|
||||
assert.equal(noon.windowEnd, '2026-10-27 07:00:00');
|
||||
assert.equal(afternoon.deadline, '2026-10-27 18:00:00');
|
||||
assert.equal(afternoon.windowEnd, '2026-10-27 16:00:00');
|
||||
assert.equal(job.resolveRoundMeta('UNKNOWN', bj(2026, 10, 27, 13, 5)), null);
|
||||
});
|
||||
@ -4,7 +4,7 @@ import request from './request';
|
||||
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
||||
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
// 仓储中心发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
||||
// params.limit 明细条数上限
|
||||
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
||||
@ -21,40 +21,3 @@ export const getWarehouseShipTimelinessRounds = () => request.get(
|
||||
'/api/warehouse-ship-timeliness/rounds',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// 「超时未发」明细导出:不分页、不截断,返回本轮考核口径内的全部行。
|
||||
// 与 /list 共用后端同一份缓存,所以页面刚刷过时这里是秒回;
|
||||
// 冷启动(缓存已过期)仍要全量扫视图,超时同样放大到 3 分钟。
|
||||
export const getWarehouseShipTimelinessExportRows = (params = {}) => request.get(
|
||||
'/api/warehouse-ship-timeliness/export',
|
||||
{
|
||||
params,
|
||||
timeout: TIMELINESS_TIMEOUT_MS,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
}
|
||||
);
|
||||
|
||||
// ── 两轮核验完成后推文本到钉钉群(2026-10-27 新增)────────────────────────
|
||||
// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选)
|
||||
// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview)
|
||||
export const getShipTimelinessNotifySettings = () => request.get(
|
||||
'/api/warehouse-ship-timeliness/notify/settings',
|
||||
{ timeout: 30000, headers: { 'Cache-Control': 'no-cache' } }
|
||||
);
|
||||
|
||||
// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填
|
||||
export const saveShipTimelinessNotifySettings = (payload = {}) => request.put(
|
||||
'/api/warehouse-ship-timeliness/notify/settings',
|
||||
payload,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// 立即推送 / 预览正文 / 测试通道
|
||||
// dryRun=true 只组装消息不发送(返回正文全文)
|
||||
// probe=true 只发一句通道测试,不查业务数据
|
||||
// 冷启动要全量扫订单状态视图(生产实测数十秒到几分钟),超时给足
|
||||
export const runShipTimelinessNotify = (payload = {}) => request.post(
|
||||
'/api/warehouse-ship-timeliness/notify/run',
|
||||
payload,
|
||||
{ timeout: 10 * 60 * 1000 }
|
||||
);
|
||||
|
||||
@ -37,7 +37,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
|
||||
{
|
||||
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||
|
||||
@ -252,15 +252,8 @@ function WarehouseLogisticsException() {
|
||||
const [activeCenter, setActiveCenter] = useState('');
|
||||
// 异常类型筛选:为空 = 全部
|
||||
const [activeType, setActiveType] = useState('');
|
||||
// 核验日;默认 null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
||||
// 🔥 钉钉消息里的链接带 `?date=YYYY-MM-DD`(见 buildPageLink),页面必须认这个参数 ——
|
||||
// 否则群里点进来看到的是「今天这批」,跟消息里说的不是同一批,历史消息会误导人。
|
||||
const [verifyDate, setVerifyDate] = useState(() => {
|
||||
const raw = String(new URLSearchParams(window.location.search).get('date') || '').trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/u.test(raw)) return null;
|
||||
const parsed = dayjs(raw);
|
||||
return parsed.isValid() ? parsed : null;
|
||||
});
|
||||
// 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
|
||||
const [verifyDate, setVerifyDate] = useState(null);
|
||||
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// 每天定点把异常文本推到钉钉群(「异常通知」)
|
||||
@ -571,16 +564,14 @@ function WarehouseLogisticsException() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{freshness && (freshness.emptyWindow || freshness.coversDeadline === false || freshness.stale) ? (
|
||||
{freshness && (freshness.coversDeadline === false || freshness.stale) ? (
|
||||
<Alert
|
||||
type={freshness.emptyWindow || freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
showIcon
|
||||
className="wle-alert"
|
||||
message={freshness.emptyWindow
|
||||
? '本批 0 单:物流镜像表里没有当天的发货记录,这个 0 不可当结论'
|
||||
: (freshness.coversDeadline === false
|
||||
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
||||
: '物流数据可能滞后')}
|
||||
message={freshness.coversDeadline === false
|
||||
? '物流快照未覆盖本轮核验时刻,异常量偏大'
|
||||
: '物流数据可能滞后'}
|
||||
description={freshness.hint}
|
||||
/>
|
||||
) : null}
|
||||
@ -598,11 +589,7 @@ function WarehouseLogisticsException() {
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
/>
|
||||
<Text type="secondary" className="wle-stat__sub">
|
||||
{notStarted
|
||||
? '—'
|
||||
: (freshness?.emptyWindow
|
||||
? '数据未同步,见上方红条'
|
||||
: `共 ${number(summary.packageCount)} 个包裹`)}
|
||||
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹
|
||||
</Text>
|
||||
</Card>
|
||||
<Card bordered={false} className="wle-stat">
|
||||
@ -793,15 +780,14 @@ function WarehouseLogisticsException() {
|
||||
: null}
|
||||
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
|
||||
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流;
|
||||
<b>现场拦截</b>(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常;
|
||||
<b>转仓代发</b>(标签如「转x00005」,货由目标仓发出)—— 不算本仓异常,也不算本仓发货量。
|
||||
<b>现场拦截</b>(售后单有签收单号且实退数量 > 0,货已被售后仓签收)—— 物流环节已结束,不算异常。
|
||||
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|
||||
|| exclusions.nonRealOrders > 0 || exclusions.transferOrders > 0) ? (
|
||||
|| exclusions.nonRealOrders > 0) ? (
|
||||
<>
|
||||
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
|
||||
+ (exclusions.nonRealOrders || 0) + (exclusions.transferOrders || 0)}</b> 单
|
||||
+ (exclusions.nonRealOrders || 0)}</b> 单
|
||||
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
|
||||
刷单/信誉单 {exclusions.nonRealOrders || 0} 单、转仓代发 {exclusions.transferOrders || 0} 单),
|
||||
刷单/信誉单 {exclusions.nonRealOrders || 0} 单),
|
||||
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
|
||||
<>
|
||||
其中承运商剔除:<b>
|
||||
@ -829,6 +815,22 @@ function WarehouseLogisticsException() {
|
||||
—— 货是被快递当场拦下原车拉回、由售后仓签收的,<b>仍计入发货分母、只是不再报异常</b>。
|
||||
</>
|
||||
) : null}
|
||||
{exclusions?.transferOrders > 0 ? (
|
||||
<>
|
||||
{' '}另有 <b>{exclusions.transferOrders}</b> 单属<b>转仓代发</b>
|
||||
{exclusions.transferReasons && Object.keys(exclusions.transferReasons).length ? (
|
||||
<>(
|
||||
{Object.entries(exclusions.transferReasons)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `标签${k} ${v} 单`)
|
||||
.join(' / ')}
|
||||
)
|
||||
</>
|
||||
) : null}
|
||||
—— 记账仓下单、实际由目标仓发出,本仓物流状态必然停在「未揽收」,
|
||||
<b>仍计入发货分母、只是不再报异常</b>。
|
||||
</>
|
||||
) : null}
|
||||
<br />
|
||||
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
|
||||
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -111,45 +111,6 @@
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* 超时卡下面的「其中 N 单数据未同步」—— 订单表停更时的警示小字(2026-10-28) */
|
||||
.wst-stat__warn {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #cf1322;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* 停更告警里逐个源列出「最后同步时间 / 滞后多久」 */
|
||||
.wst-alert__sources {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
.wst-alert__source {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 明细里的「内部单号 + 未同步标记」 */
|
||||
.wst-detail__oid {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wst-detail__stale {
|
||||
margin-inline-end: 0;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
padding-inline: 5px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* ============ 主从两栏 ============ */
|
||||
.wst-body {
|
||||
display: flex;
|
||||
@ -292,70 +253,3 @@
|
||||
gap: 2px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 钉钉通知(两轮核验完成后各推一次)───────────────────────────── */
|
||||
.wst-notify__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wst-notify__label {
|
||||
flex: 0 0 88px;
|
||||
font-size: 13px;
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
.wst-notify__last {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wst-notify__error {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.wst-notify__help {
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
.wst-notify__help code {
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.wst-notify__preview {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--sycm-border, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 8px;
|
||||
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.wst-notify__preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wst-notify__preview pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
/* 30 条明细大概 40 行,别把整个弹窗撑爆;内部滚动看全 */
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user