cangku/backend/src/services/warehouseShipTimelinessNotifySettings.js
yanhui 6a5f6e8ae0 refactor(wst): 页面名去掉「24 小时」,改为「发货时效(到点未发即超时)」
规则完全不动,只改名字,消除误导。

## 为什么改

页面原名「仓储中心 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`:本次只动注释,不影响
2026-10-28 14:30:54 +08:00

374 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};