forked from lunanting/cangku
规则完全不动,只改名字,消除误导。 ## 为什么改 页面原名「仓储中心 24 小时发货时效」,但判定规则是「到 13:00 / 18:00 核验时刻仍未发货即超时」,**没有满 24 小时的门槛**(业务 2026-10-20 已三次纠偏定版)。 名字和规则打架,导致看的人以为「不满 24 小时不该算超时」。 实测例子:10-27 16:00 创建的单,今天 13:00 核验时只等了 **21 小时**就被判超时; 13:00 轮 599 单里有 416 单属于 10-27 16 点后创建。业务看到就问「创建时间都在 7 点后, 为什么算超时」—— 其实是名字里的「24 小时」在误导(那些是**昨天**晚上的单, 不是今天的)。 ## 改了什么 用户可见(2 处): - 侧栏菜单:`仓储中心24小时发货时效` → `仓储中心发货时效` (不加括号:侧栏现有最长 label 12 字,加了会到 18 字溢出) - 页面标题:`仓储中心 24 小时发货时效` → `仓储中心发货时效(到点未发即超时)` 其余为注释同步(8 个文件),代码逻辑与判定口径**零改动**: `warehouseShipTimelinessRegistry/Service/NotifyJob/NotifySettings`、 `warehouseLogisticsExceptionRegistry`、`frontend/api/warehouseShipTimeliness`、`backend/index`。 ## 不变的部分 - 核验时点、窗口上界(07:00 / 16:00)、仓级例外(山东 15:00、洛宁 13:00) - 数据源与起算点(订单两表 + `created`) - 钉钉推送抬头本来就是「【仓储中心发货时效】」,不用改 ## 验证 - 前端 eslint 通过、`npm run build` 通过(31.86s) - 浏览器真渲染(本地 5173,等足 75 秒数据返回): - 标题 = `仓储中心发货时效(到点未发即超时)` - 侧栏项 = `仓储中心发货时效`,全页 `body` 内已无「24小时」字样 - 数据正常渲染:超时未发 599 单 / 不计考核 849 / 涉及仓库 8 个 / 最久未发 13 天 - 明细「创建时间」列完整显示 `2026-10-27 19:xx` 这类日期,不存在时区错位 - 无 pageerror - 后端 `npm run check`:本次只动注释,不影响
374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
'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,
|
||
};
|