forked from lunanting/cangku
- 补货预警:接入加工单(链接服务器 bi_processing_work_orders),有未完成加工单则不预警,新增在途加工量列 - 新增每日 09:00 缺货/预警通知推送(cron + 统一通知中心 + 幂等键) - 新增导出:商品清单 / 配件明细,XLSX / CSV,按当前筛选条件全量 - 配件仓位:新增 loadComponentLocations 批量加载器(导出/列表展开共用)
228 lines
8.5 KiB
JavaScript
228 lines
8.5 KiB
JavaScript
'use strict';
|
||
|
||
// MKH 成品补货预警推送 —— 配置 + 推送状态(本地库 system_config)
|
||
//
|
||
// ── 配置键 mkh_replenish_alert_config ─────────────────────────────
|
||
// { enabled, recipientAlias, scope }
|
||
// · recipientAlias 为空时回退顺序:环境变量 → 统一通知中心默认收件人
|
||
// · scope: 'warning'(缺货 + 3 天内预警,默认)| 'outOfStock'(仅缺货)
|
||
//
|
||
// ── 状态键 mkh_replenish_alert_state ──────────────────────────────
|
||
// { version, notified: { [skuId]: { level, firstNotifiedAt, lastNotifiedAt, ... } }, lastRunAt }
|
||
// · 用于实现「只提醒新出现的预警」:已在 notified 里的商品不再重复推送;
|
||
// 商品恢复到正常范围后从 notified 移除,下次再预警会重新提醒。
|
||
|
||
const database = require('../config/database');
|
||
const {
|
||
getYydNotificationCenterRuntimeConfig,
|
||
toAdminConfig: toNotificationCenterAdminConfig,
|
||
} = require('./yydNotificationCenterSettings');
|
||
|
||
const CONFIG_KEY = 'mkh_replenish_alert_config';
|
||
const STATE_KEY = 'mkh_replenish_alert_state';
|
||
const CONFIG_CACHE_TTL_MS = 30 * 1000;
|
||
|
||
const SCOPES = Object.freeze({ WARNING: 'warning', OUT_OF_STOCK: 'outOfStock' });
|
||
|
||
const SCOPE_OPTIONS = Object.freeze([
|
||
{ value: SCOPES.WARNING, label: '缺货 + 3天内预警' },
|
||
{ value: SCOPES.OUT_OF_STOCK, label: '仅缺货' },
|
||
]);
|
||
|
||
let cachedConfig = null;
|
||
let cachedAt = 0;
|
||
|
||
function normalizeText(value, maxLength = 200) {
|
||
return String(value ?? '').trim().slice(0, maxLength);
|
||
}
|
||
|
||
function safeJsonParse(value, fallback = {}) {
|
||
if (value && typeof value === 'object') return value;
|
||
try {
|
||
const parsed = JSON.parse(String(value || ''));
|
||
return parsed && typeof parsed === 'object' ? parsed : fallback;
|
||
} catch (_error) {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function normalizeScope(value) {
|
||
const text = normalizeText(value, 40).toLowerCase();
|
||
if (!text) return SCOPES.WARNING;
|
||
if (text === 'outofstock' || text === 'out_of_stock' || text === '缺货') return SCOPES.OUT_OF_STOCK;
|
||
if (text === 'warning' || text === 'warn' || text === '预警' || text === 'all') return SCOPES.WARNING;
|
||
return SCOPES.WARNING;
|
||
}
|
||
|
||
function normalizeFlag(value, fallback = true) {
|
||
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 readSystemConfigValue(key) {
|
||
const rows = await database.local.query(
|
||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||
[key]
|
||
);
|
||
return rows?.[0]?.configValue;
|
||
}
|
||
|
||
async function upsertSystemConfigValue(key, value, configType = 'json') {
|
||
const payload = typeof value === 'string' ? value : JSON.stringify(value);
|
||
const rows = await database.local.query(
|
||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||
[key]
|
||
);
|
||
if (rows?.[0]) {
|
||
await database.local.query(
|
||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||
[payload, configType, key]
|
||
);
|
||
} else {
|
||
await database.local.insert('system_config', {
|
||
config_key: key,
|
||
config_value: payload,
|
||
config_type: configType,
|
||
});
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
// ── 配置 ────────────────────────────────────────────────────────
|
||
async function getMkhReplenishAlertRuntimeConfig({ forceRefresh = false } = {}) {
|
||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) {
|
||
return { ...cachedConfig };
|
||
}
|
||
let stored = {};
|
||
try {
|
||
stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {});
|
||
} catch (error) {
|
||
console.warn('读取补货预警推送配置失败,使用默认值:', error.message);
|
||
}
|
||
cachedConfig = {
|
||
enabled: normalizeFlag(stored.enabled, true),
|
||
recipientAlias: normalizeText(stored.recipientAlias || stored.recipient_alias, 50),
|
||
scope: normalizeScope(stored.scope),
|
||
recipientAliasFallback: normalizeText(process.env.MKH_REPLENISH_ALERT_RECIPIENT_ALIAS, 50),
|
||
};
|
||
cachedAt = Date.now();
|
||
return { ...cachedConfig };
|
||
}
|
||
|
||
/**
|
||
* 解析实际收件人 alias:配置填写值 → 环境变量 → 统一通知中心默认收件人。
|
||
*/
|
||
async function resolveMkhReplenishAlertRecipient(options = {}) {
|
||
const config = options.config || await getMkhReplenishAlertRuntimeConfig();
|
||
if (config.recipientAlias) return { alias: config.recipientAlias, source: 'config' };
|
||
if (config.recipientAliasFallback) return { alias: config.recipientAliasFallback, source: 'environment' };
|
||
let center = {};
|
||
try {
|
||
center = await getYydNotificationCenterRuntimeConfig({ forceRefresh: options.forceRefresh === true });
|
||
} catch (_error) {
|
||
center = {};
|
||
}
|
||
const alias = normalizeText(center?.recipientAlias, 50);
|
||
return { alias, source: alias ? 'notification_center' : 'none' };
|
||
}
|
||
|
||
async function getMkhReplenishAlertAdminConfig() {
|
||
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||
const recipient = await resolveMkhReplenishAlertRecipient({ config });
|
||
let center = {};
|
||
try {
|
||
center = await getYydNotificationCenterRuntimeConfig();
|
||
} catch (_error) {
|
||
center = {};
|
||
}
|
||
const centerAdmin = toNotificationCenterAdminConfig(center);
|
||
const state = await loadMkhReplenishAlertState();
|
||
return {
|
||
enabled: config.enabled,
|
||
scope: config.scope,
|
||
recipientAlias: config.recipientAlias,
|
||
effectiveRecipientAlias: recipient.alias || '',
|
||
recipientSource: recipient.source,
|
||
notificationCenterConfigured: centerAdmin.configured,
|
||
notificationCenterBaseUrl: centerAdmin.baseUrl,
|
||
notificationCenter: {
|
||
configured: centerAdmin.configured,
|
||
baseUrl: centerAdmin.baseUrl,
|
||
projectCode: centerAdmin.projectCode,
|
||
projectSecretConfigured: centerAdmin.projectSecretConfigured,
|
||
projectSecretPreview: centerAdmin.projectSecretPreview,
|
||
recipientAlias: centerAdmin.recipientAlias,
|
||
},
|
||
notifiedCount: Object.keys(state.notified || {}).length,
|
||
lastRunAt: state.lastRunAt || null,
|
||
scopeOptions: SCOPE_OPTIONS.map((item) => ({ ...item })),
|
||
};
|
||
}
|
||
|
||
async function saveMkhReplenishAlertConfig(input = {}) {
|
||
const current = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||
const rawAlias = input.recipientAlias ?? input.recipient_alias;
|
||
const next = {
|
||
enabled: normalizeFlag(input.enabled, current.enabled),
|
||
recipientAlias: normalizeText(rawAlias === undefined ? current.recipientAlias : rawAlias, 50),
|
||
scope: normalizeScope(input.scope || current.scope),
|
||
};
|
||
await upsertSystemConfigValue(CONFIG_KEY, next);
|
||
cachedConfig = { ...next, recipientAliasFallback: current.recipientAliasFallback };
|
||
cachedAt = Date.now();
|
||
return getMkhReplenishAlertAdminConfig();
|
||
}
|
||
|
||
// ── 推送状态 ────────────────────────────────────────────────────
|
||
async function loadMkhReplenishAlertState() {
|
||
let parsed = {};
|
||
try {
|
||
parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {});
|
||
} catch (error) {
|
||
console.warn('读取补货预警推送状态失败,按空状态处理:', error.message);
|
||
}
|
||
const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified)
|
||
? parsed.notified
|
||
: {};
|
||
return {
|
||
version: 1,
|
||
notified,
|
||
lastRunAt: normalizeText(parsed.lastRunAt, 40) || null,
|
||
};
|
||
}
|
||
|
||
async function saveMkhReplenishAlertState(state = {}) {
|
||
const value = {
|
||
version: 1,
|
||
notified: state.notified && typeof state.notified === 'object' ? state.notified : {},
|
||
lastRunAt: normalizeText(state.lastRunAt, 40) || null,
|
||
};
|
||
await upsertSystemConfigValue(STATE_KEY, value);
|
||
return value;
|
||
}
|
||
|
||
/** 清空「已推送」记录,让下一次检查重新按新增口径判断(便于测试)。 */
|
||
async function resetMkhReplenishAlertState() {
|
||
return saveMkhReplenishAlertState({ notified: {}, lastRunAt: null });
|
||
}
|
||
|
||
module.exports = {
|
||
CONFIG_KEY,
|
||
STATE_KEY,
|
||
SCOPES,
|
||
SCOPE_OPTIONS,
|
||
getMkhReplenishAlertAdminConfig,
|
||
getMkhReplenishAlertRuntimeConfig,
|
||
loadMkhReplenishAlertState,
|
||
normalizeFlag,
|
||
normalizeScope,
|
||
resetMkhReplenishAlertState,
|
||
resolveMkhReplenishAlertRecipient,
|
||
saveMkhReplenishAlertConfig,
|
||
saveMkhReplenishAlertState,
|
||
};
|