Merge PR #33: 每日进销存增加定时截图通知

经仓库所有者 youruo 明确授权合并(2026-09-24 授权),授权覆盖最终提交 3bfc48b8b36a22ab422a6566c1637fc7d5f1c42a
This commit is contained in:
youruo 2026-09-27 08:13:03 +08:00
commit daf3646470
10 changed files with 1528 additions and 4 deletions

View File

@ -3,12 +3,17 @@ FROM node:22-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
# 每日进销存定时截图需要 Chromium 与中文字体:
# · fonts-noto-cjk —— 否则截图里所有中文都会变成方块
# · playwright install --with-deps chromium —— 默认镜像不带浏览器
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& apt-get install -y --no-install-recommends ca-certificates fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
RUN npm ci --omit=dev --no-audit --no-fund \
&& npx playwright install --with-deps chromium \
&& rm -rf /var/lib/apt/lists/* /root/.npm/_cacache
COPY . .

View File

@ -0,0 +1,81 @@
'use strict';
// 每日进销存 · 定时截图通知(管理员专用)
//
// 安全约定:
// · 全部接口要求登录且 role === 'admin'
// · 响应里只有「是否已配置」与脱敏预览,绝不返回通知中心密钥 / 对象存储密钥
// · 收件人名单由后端校验与规范化,前端只能提交姓名文本
const express = require('express');
const {
getDailyInventoryNotificationAdminConfig,
listRunRecords,
saveDailyInventoryNotificationConfig,
} = require('../services/dailyInventoryNotificationSettings');
const {
runDailyInventoryNotificationJob,
} = require('../jobs/dailyInventoryNotificationJob');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[DailyInventoryNotification]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
/** 仅管理员可查看与修改通知设置、执行立即测试 */
function requireAdmin(req, res, next) {
if (req?.user?.role !== 'admin') {
return res.status(403).json({ success: false, error: '仅管理员可操作通知设置' });
}
return next();
}
router.use(requireAdmin);
// 读取通知设置(含通知中心 / 对象存储的配置状态与最近执行记录)
router.get('/config', async (_req, res) => {
try {
res.json({ success: true, data: await getDailyInventoryNotificationAdminConfig() });
} catch (error) {
sendError(res, error, '加载每日进销存通知设置失败');
}
});
// 保存通知设置
router.put('/config', async (req, res) => {
try {
const saved = await saveDailyInventoryNotificationConfig(req.body || {});
res.json({ success: true, data: { ...saved, admin: await getDailyInventoryNotificationAdminConfig() } });
} catch (error) {
sendError(res, error, '保存每日进销存通知设置失败');
}
});
// 立即测试:真实走一次截图 + 上传 + 通知,但不写入正式幂等记录
router.post('/test', async (_req, res) => {
try {
const result = await runDailyInventoryNotificationJob({ mode: 'test' });
res.json({ success: result?.success === true, data: result });
} catch (error) {
sendError(res, error, '执行每日进销存通知测试失败');
}
});
// 最近执行记录(管理员排障用)
router.get('/runs', async (req, res) => {
try {
const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 50);
res.json({ success: true, data: await listRunRecords(limit) });
} catch (error) {
sendError(res, error, '加载每日进销存通知执行记录失败');
}
});
module.exports = router;

View File

@ -36,6 +36,7 @@ const productMonitoringRoutes = require('./api/productMonitoring');
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
const mkhBomRoutes = require('./api/mkhBom');
const dailyInventoryRoutes = require('./api/dailyInventory');
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
const returnMonitorRoutes = require('./api/returnMonitor');
const returnStockWarningRoutes = require('./api/returnStockWarning');
@ -54,6 +55,9 @@ const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
const {
startDailyInventorySnapshotScheduler,
} = require('./jobs/dailyInventorySnapshotJob');
const {
startDailyInventoryNotificationScheduler,
} = require('./jobs/dailyInventoryNotificationJob');
const {
startAlibaba1688ImageSearchExpiryScheduler,
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
@ -667,6 +671,7 @@ async function startServer(options = {}) {
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
@ -756,6 +761,11 @@ async function startServer(options = {}) {
console.log(` [缓存] ${redisTest.message}`);
console.log('='.repeat(50));
// 每日进销存截图通知:与「后台定时任务总开关」解耦,保证即使总开关关闭,
// 只要功能启用就仍会按配置时间推送(可用 DAILY_INVENTORY_NOTIFICATION_ENABLED=false 单独关闭)。
// 它每分钟只做一次轻量判断,命中配置时间才真正截图上传。
startDailyInventoryNotificationScheduler();
if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') {
console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理');
return;

View File

@ -0,0 +1,350 @@
'use strict';
// 每日进销存 · 定时截图通知
//
// 执行流程:
// ① 单实例锁(MySQL GET_LOCK),避免多实例重复截图推送
// ② 校验:功能开启 / 有收件人 / 通知中心已配置 / 对象存储已配置,任一不满足就记录原因并退出
// ③ 按配置时间(默认 01:00 Asia/Shanghai)触发;业务日期 = 北京时间昨日(与页面 T-1 口径一致)
// ④ 截图 → 上传对象存储 → 拿到公开 HTTPS 地址后才发通知
// ⑤ 逐用户发送,用「业务日期 + 收件人」幂等键去重;单人失败不影响其他人
// ⑥ 截图/上传/通知失败均做有限次数重试与指数退避
// ⑦ 每次执行都落一条记录(时间 / 截图地址 / 通知对象 / 成功状态 / 失败原因)
//
// 立即测试(mode='test'):走同一条链路,但幂等键带随机数,**不写入正式幂等记录**,
// 因此不会影响下一次正式定时推送。
const crypto = require('crypto');
const cron = require('node-cron');
const database = require('../config/database');
const {
appendRunRecord,
buildIdempotencyKey,
getDailyInventoryNotificationRuntimeConfig,
getObjectStorageStatus,
hasNotified,
loadDailyInventoryNotificationState,
markNotified,
normalizeRecipients,
} = require('../services/dailyInventoryNotificationSettings');
const {
captureAndUploadDailyInventory,
chinaTimeParts,
resolvePageUrl,
} = require('../services/dailyInventoryScreenshot');
const {
getYydNotificationCenterRuntimeConfig,
isYydNotificationCenterConfigured,
sendYydNotification,
} = require('../services/yydNotificationCenterClient');
const TIMEZONE = 'Asia/Shanghai';
const LOCK_KEY = 'daily_inventory_notification_job';
const LOCK_WAIT_SECONDS = 2;
const NOTIFICATION_TITLE = '每日进销存数据';
const ACTION_TITLE = '查看每日进销存';
// 重试策略:最多 3 次,退避 2s / 4s
const MAX_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 2000;
// 命中时间后允许补跑的窗口与次数上限(防止全天每分钟重试刷屏)
const RETRY_WINDOW_MINUTES = 30;
const MAX_SCHEDULED_ATTEMPTS_PER_DAY = 3;
let running = false;
let timer = null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** 有限次数重试 + 指数退避 */
async function withRetry(task, { label, attempts = MAX_ATTEMPTS, baseDelayMs = RETRY_BASE_DELAY_MS } = {}) {
let lastError = null;
for (let index = 0; index < attempts; index += 1) {
try {
return await task();
} catch (error) {
lastError = error;
const delay = baseDelayMs * (2 ** index);
console.error(
`[DailyInventoryNotify] ${label} 第 ${index + 1}/${attempts} 次失败:${error.message}`
+ `${index < attempts - 1 ? `,${delay}ms 后重试` : ''}`
);
if (index < attempts - 1) await sleep(delay);
}
}
throw lastError;
}
/** 业务日期 = 北京时间昨日(页面标注 T-1,凌晨跑批产出昨日快照) */
function resolveBizDate(now = new Date()) {
const target = now instanceof Date ? now : new Date(now);
const shifted = new Date(target.getTime() - 24 * 60 * 60 * 1000);
return chinaTimeParts(shifted).bizDate;
}
function beijingTimeText(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat('zh-CN', {
timeZone: TIMEZONE,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).format(target);
}
function currentBeijingHhMm(now = new Date()) {
const text = new Intl.DateTimeFormat('en-GB', {
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', hour12: false,
}).format(now instanceof Date ? now : new Date(now));
return text;
}
/** 全实例锁:拿到锁才继续执行,拿不到说明其它实例正在跑 */
async function withJobLock(runner) {
const pool = database.local.pool;
let connection = null;
try {
connection = await pool.getConnection();
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [LOCK_KEY, LOCK_WAIT_SECONDS]);
const acquired = Number(rows?.[0]?.lockStatus || 0) === 1;
if (!acquired) {
return { success: false, skipped: true, reason: 'lock_not_acquired' };
}
return await runner();
} catch (error) {
return { success: false, skipped: true, reason: 'lock_error', error: error.message };
} finally {
if (connection) {
try {
await connection.query('DO RELEASE_LOCK(?)', [LOCK_KEY]);
} catch (_error) {
// ignore
}
connection.release();
}
}
}
function buildNotificationContent({ bizDate, takenAt, imageUrl, pageUrl }) {
return [
`**数据日期:** ${bizDate}`,
`**截图生成时间:** ${takenAt}(${TIMEZONE})`,
`**图片地址:** ${imageUrl}`,
`**页面地址:** ${pageUrl}`,
'',
`点击右下角「${ACTION_TITLE}」可直达每日进销存页面。`,
].join('\n');
}
/**
* 执行一次(正式 / 测试)
* @param {Object} options
* mode 'scheduled' | 'manual' | 'test'
* force true 时忽略「今天已调度过」的节流判断(手动触发用)
* now 指定执行时刻,便于测试
*/
async function runDailyInventoryNotificationJob(options = {}) {
const mode = options.mode || 'scheduled';
const now = options.now ? new Date(options.now) : new Date();
const startedAt = now.toISOString();
if (running) {
return { success: false, skipped: true, reason: 'already_running' };
}
running = true;
try {
return await withJobLock(async () => {
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
const recipients = normalizeRecipients(config.recipients);
const bizDate = resolveBizDate(now);
const finish = async (payload) => {
await appendRunRecord({ ...payload, runAt: startedAt, bizDate, mode, notifyTime: config.notifyTime });
return payload;
};
if (!config.enabled) {
return finish({ success: false, reason: 'disabled', error: '功能未启用' });
}
if (!recipients.length) {
return finish({ success: false, reason: 'no_recipient', error: '未配置通知用户' });
}
let centerConfig = null;
try {
centerConfig = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
centerConfig = null;
}
if (!isYydNotificationCenterConfigured(centerConfig)) {
return finish({ success: false, reason: 'notification_center_not_configured', error: '统一通知中心未配置' });
}
const storage = await getObjectStorageStatus();
if (!storage.configured) {
return finish({ success: false, reason: 'object_storage_not_configured', error: '对象存储未配置' });
}
// 截图 → 上传(失败直接结束,绝不发通知)
let shot;
try {
shot = await withRetry(
() => captureAndUploadDailyInventory({ now }),
{ label: '截图并上传' }
);
} catch (error) {
return finish({ success: false, reason: 'screenshot_failed', error: error.message });
}
const pageUrl = resolvePageUrl();
const takenAt = beijingTimeText(now);
const nonce = crypto.randomBytes(8).toString('hex');
const deliveries = [];
for (const recipient of recipients) {
const idempotencyKey = buildIdempotencyKey({ bizDate, recipient, mode, nonce });
// 正式推送先查幂等:同一业务日期 + 同一用户只成功通知一次
if (mode !== 'test' && await hasNotified(bizDate, recipient)) {
deliveries.push({ recipient, status: 'skipped', reason: 'already_notified' });
continue;
}
try {
const response = await withRetry(
() => sendYydNotification({
idempotencyKey,
recipientAlias: recipient,
title: NOTIFICATION_TITLE,
content: buildNotificationContent({
bizDate, takenAt, imageUrl: shot.url, pageUrl,
}),
actionTitle: ACTION_TITLE,
actionUrl: pageUrl,
}),
{ label: `通知 ${recipient}` }
);
const deliveryId = response?.id || response?.data?.id || null;
if (mode !== 'test') await markNotified(bizDate, recipient, { deliveryId, idempotencyKey });
deliveries.push({ recipient, status: 'sent', deliveryId });
} catch (error) {
// 单人失败不阻断其他人
console.error(`[DailyInventoryNotify] 通知 ${recipient} 失败:${error.message}`);
deliveries.push({ recipient, status: 'failed', error: error.message });
}
}
const sentCount = deliveries.filter((d) => d.status === 'sent').length;
const failedCount = deliveries.filter((d) => d.status === 'failed').length;
const success = sentCount > 0;
console.log(
`[DailyInventoryNotify] ${mode} 完成:业务日 ${bizDate},截图 ${shot.url},`
+ `通知成功 ${sentCount} / 失败 ${failedCount} / 跳过 ${deliveries.length - sentCount - failedCount}`
);
return finish({
success,
reason: success ? null : 'notify_failed',
error: success ? null : '所有收件人均通知失败',
screenshotUrl: shot.url,
screenshotKey: shot.key,
rowCount: shot.rowCount,
recipients: deliveries,
});
});
} finally {
running = false;
}
}
/** 同一业务日期已经发起过的正式调度次数与最后一次结果(用于失败重试节流) */
async function scheduledAttemptStatus(bizDate) {
const state = await loadDailyInventoryNotificationState();
const runs = (Array.isArray(state.runs) ? state.runs : [])
.filter((r) => r.bizDate === bizDate && r.mode === 'scheduled');
return {
count: runs.length,
lastSuccess: runs.length ? runs[runs.length - 1].success === true : null,
};
}
function minutesOfDay(hhmm) {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || ''));
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return null;
return hour * 60 + minute;
}
/**
* 是否应当发起一次正式调度:
* · 恰好命中配置时间 → 执行
* · 命中时间之后的 RETRY_WINDOW_MINUTES 分钟内 → 仅在上一次失败且未超出重试上限时补跑
* · 其余时间 → 不执行
*/
async function shouldRunScheduled(config, now) {
const target = minutesOfDay(config.notifyTime);
const current = minutesOfDay(currentBeijingHhMm(now));
if (target === null || current === null) return false;
const diff = current - target;
if (diff < 0 || diff > RETRY_WINDOW_MINUTES) return false;
if (diff === 0) return true;
const status = await scheduledAttemptStatus(resolveBizDate(now));
return status.lastSuccess === false && status.count < MAX_SCHEDULED_ATTEMPTS_PER_DAY;
}
function startDailyInventoryNotificationScheduler() {
const disabled = String(process.env.DAILY_INVENTORY_NOTIFICATION_ENABLED || 'true').trim().toLowerCase() === 'false';
if (disabled) {
console.log('[DailyInventoryNotify] 已通过环境变量关闭');
return null;
}
if (timer) return timer;
// 每分钟检查一次:命中配置的通知时间才真正执行,配置改动无需重启
timer = cron.schedule('* * * * *', async () => {
try {
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
if (!config.enabled) return;
const now = new Date();
if (!(await shouldRunScheduled(config, now))) return;
await runDailyInventoryNotificationJob({ mode: 'scheduled', now });
} catch (error) {
console.error('[DailyInventoryNotify] 定时检查失败:', error.message);
}
}, { timezone: TIMEZONE });
console.log(
`[Scheduler] 每日进销存截图通知已注册(按配置时间触发,${TIMEZONE},`
+ `失败后 ${RETRY_WINDOW_MINUTES} 分钟内最多补跑 ${MAX_SCHEDULED_ATTEMPTS_PER_DAY} 次)`
);
return timer;
}
function stopDailyInventoryNotificationScheduler() {
if (timer) {
timer.stop();
timer = null;
}
}
module.exports = {
ACTION_TITLE,
LOCK_KEY,
MAX_ATTEMPTS,
MAX_SCHEDULED_ATTEMPTS_PER_DAY,
NOTIFICATION_TITLE,
RETRY_BASE_DELAY_MS,
TIMEZONE,
RETRY_WINDOW_MINUTES,
beijingTimeText,
buildNotificationContent,
currentBeijingHhMm,
minutesOfDay,
resolveBizDate,
runDailyInventoryNotificationJob,
startDailyInventoryNotificationScheduler,
stopDailyInventoryNotificationScheduler,
withRetry,
};

View File

@ -0,0 +1,330 @@
'use strict';
// 每日进销存 · 定时截图通知 —— 配置 + 执行记录 + 幂等记录(本地库 system_config)
//
// ── 配置键 daily_inventory_notification_config ────────────────────
// { enabled, notifyTime: 'HH:mm', recipients: ['张三', '李四'] }
//
// ── 状态键 daily_inventory_notification_state ──────────────────────
// { version, notified: { 'YYYY-MM-DD': { '别名': { notifiedAt, deliveryId } } }, runs: [...] }
// · notified 用于「同一业务日期 + 同一用户只成功通知一次」的幂等判断
// · runs 保留最近 N 次执行明细,供管理员在页面查询
//
// 安全约定:本文件对外只返回「是否已配置」这类布尔值与脱敏预览,
// 绝不返回通知中心 projectSecret、对象存储 secretAccessKey 等密钥。
const database = require('../config/database');
const {
getYydNotificationCenterRuntimeConfig,
toAdminConfig: toNotificationCenterAdminConfig,
} = require('./yydNotificationCenterSettings');
const { getDefaultS3Config } = require('./objectStorage');
const CONFIG_KEY = 'daily_inventory_notification_config';
const STATE_KEY = 'daily_inventory_notification_state';
const DEFAULT_NOTIFY_TIME = '01:00';
const TIMEZONE = 'Asia/Shanghai';
const MAX_RECIPIENTS = 20;
const MAX_RECIPIENT_LENGTH = 50;
const MAX_RUN_RECORDS = 30;
const CONFIG_CACHE_TTL_MS = 30 * 1000;
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 normalizeFlag(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;
}
/**
* 通知时间规范化:只接受 HH:mm(24 小时制),非法值回退默认 01:00。
* 允许传 'HH:mm:ss',截断到分钟。
*/
function normalizeNotifyTime(value) {
const text = normalizeText(value, 10);
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(text);
if (!match) return DEFAULT_NOTIFY_TIME;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || hour < 0 || hour > 23) return DEFAULT_NOTIFY_TIME;
if (!Number.isInteger(minute) || minute < 0 || minute > 59) return DEFAULT_NOTIFY_TIME;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
/**
* 收件人名单规范化:trim → 去空 → 去重(保持输入顺序)→ 限长限量。
* 支持数组、逗号/换行/分号分隔的字符串。
*/
function normalizeRecipients(input) {
let list = [];
if (Array.isArray(input)) {
list = input;
} else if (typeof input === 'string') {
list = input.split(/[,,;;\n\r\t]+/);
} else if (input && typeof input === 'object') {
list = Object.values(input);
}
const seen = new Set();
const result = [];
for (const raw of list) {
const name = normalizeText(raw, MAX_RECIPIENT_LENGTH);
if (!name) continue;
if (seen.has(name)) continue;
seen.add(name);
result.push(name);
if (result.length >= MAX_RECIPIENTS) break;
}
return result;
}
/** 幂等键:正式推送按「业务日期 + 收件人」去重;测试推送带时间戳,不污染正式记录 */
function buildIdempotencyKey({ bizDate, recipient, mode = 'scheduled', nonce = '' }) {
const scope = mode === 'test' ? `test:${normalizeText(nonce, 64)}` : 'daily';
return `daily_inventory_notification:${scope}:${bizDate}:${recipient}:v1`.slice(0, 160);
}
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 getDailyInventoryNotificationRuntimeConfig({ 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, false),
notifyTime: normalizeNotifyTime(stored.notifyTime ?? stored.notify_time),
recipients: normalizeRecipients(stored.recipients),
};
cachedAt = Date.now();
return { ...cachedConfig };
}
async function saveDailyInventoryNotificationConfig(input = {}) {
const current = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
const next = {
enabled: normalizeFlag(input.enabled, current.enabled),
notifyTime: normalizeNotifyTime(
input.notifyTime ?? input.notify_time ?? current.notifyTime
),
recipients: normalizeRecipients(
input.recipients === undefined ? current.recipients : input.recipients
),
};
await upsertSystemConfigValue(CONFIG_KEY, next);
cachedConfig = { ...next };
cachedAt = Date.now();
return { ...next };
}
// ── 执行记录 / 幂等记录 ─────────────────────────────────────────
async function loadDailyInventoryNotificationState() {
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
: {};
const runs = Array.isArray(parsed.runs) ? parsed.runs : [];
return { version: 1, notified, runs };
}
async function saveDailyInventoryNotificationState(state = {}) {
const value = {
version: 1,
notified: state.notified && typeof state.notified === 'object' && !Array.isArray(state.notified)
? state.notified
: {},
runs: Array.isArray(state.runs) ? state.runs.slice(-MAX_RUN_RECORDS) : [],
};
await upsertSystemConfigValue(STATE_KEY, value);
return value;
}
/** 判断某业务日期是否已成功通知过某人(只认正式推送) */
async function hasNotified(bizDate, recipient) {
const state = await loadDailyInventoryNotificationState();
const day = state.notified?.[bizDate];
return Boolean(day && day[recipient]);
}
/** 记录一次成功通知(正式推送才写;测试推送不写) */
async function markNotified(bizDate, recipient, extra = {}) {
const state = await loadDailyInventoryNotificationState();
const day = { ...(state.notified?.[bizDate] || {}) };
day[recipient] = {
notifiedAt: new Date().toISOString(),
deliveryId: extra.deliveryId || null,
idempotencyKey: extra.idempotencyKey || null,
};
const notified = { ...(state.notified || {}), [bizDate]: day };
// 只保留最近 14 个业务日,避免状态无限膨胀
const keys = Object.keys(notified).sort();
const trimmed = {};
keys.slice(-14).forEach((k) => { trimmed[k] = notified[k]; });
return saveDailyInventoryNotificationState({ ...state, notified: trimmed });
}
/** 追加一条执行记录 */
async function appendRunRecord(record = {}) {
const state = await loadDailyInventoryNotificationState();
const runs = [...(state.runs || []), {
runAt: record.runAt || new Date().toISOString(),
bizDate: record.bizDate || null,
mode: record.mode || 'scheduled',
success: record.success === true,
reason: record.reason || null,
error: record.error || null,
screenshotUrl: record.screenshotUrl || null,
screenshotKey: record.screenshotKey || null,
notifyTime: record.notifyTime || null,
recipients: Array.isArray(record.recipients) ? record.recipients : [],
}];
return saveDailyInventoryNotificationState({ ...state, runs });
}
function listRunRecords(limit = 10) {
return loadDailyInventoryNotificationState().then((state) => {
const runs = Array.isArray(state.runs) ? state.runs : [];
return runs.slice(-Math.max(Number(limit) || 10, 1)).reverse();
});
}
// ── 管理员视图(不含任何密钥) ───────────────────────────────────
async function getObjectStorageStatus() {
try {
const config = await getDefaultS3Config(false);
return {
configured: Boolean(config && config.bucket && config.endpoint && config.accessKeyId),
bucket: config?.bucket || '',
endpoint: config?.endpoint || '',
// 只给脱敏信息,secretAccessKey 从不读取、从不返回
accessKeyIdPreview: config?.accessKeyId
? `${String(config.accessKeyId).slice(0, 4)}***`
: '',
};
} catch (error) {
return { configured: false, bucket: '', endpoint: '', accessKeyIdPreview: '', error: error.message };
}
}
async function getDailyInventoryNotificationAdminConfig() {
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
let center = {};
try {
center = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
center = {};
}
const centerAdmin = toNotificationCenterAdminConfig(center);
const storage = await getObjectStorageStatus();
const state = await loadDailyInventoryNotificationState();
const runs = Array.isArray(state.runs) ? state.runs : [];
const blockingReasons = [];
if (!config.enabled) blockingReasons.push('功能未启用');
if (!config.recipients.length) blockingReasons.push('未配置通知用户');
if (!centerAdmin.configured) blockingReasons.push('统一通知中心未配置');
if (!storage.configured) blockingReasons.push('对象存储未配置');
return {
enabled: config.enabled,
notifyTime: config.notifyTime,
recipients: config.recipients,
timezone: TIMEZONE,
maxRecipients: MAX_RECIPIENTS,
notificationCenter: {
configured: centerAdmin.configured,
baseUrl: centerAdmin.baseUrl,
projectCode: centerAdmin.projectCode,
projectSecretConfigured: centerAdmin.projectSecretConfigured,
projectSecretPreview: centerAdmin.projectSecretPreview,
},
objectStorage: storage,
blockingReasons,
lastRunAt: runs.length ? runs[runs.length - 1].runAt : null,
lastRunSuccess: runs.length ? runs[runs.length - 1].success === true : null,
runs: runs.slice(-10).reverse(),
};
}
module.exports = {
CONFIG_KEY,
STATE_KEY,
DEFAULT_NOTIFY_TIME,
MAX_RECIPIENTS,
MAX_RECIPIENT_LENGTH,
MAX_RUN_RECORDS,
TIMEZONE,
appendRunRecord,
buildIdempotencyKey,
getDailyInventoryNotificationAdminConfig,
getDailyInventoryNotificationRuntimeConfig,
getObjectStorageStatus,
hasNotified,
listRunRecords,
loadDailyInventoryNotificationState,
markNotified,
normalizeFlag,
normalizeNotifyTime,
normalizeRecipients,
normalizeText,
saveDailyInventoryNotificationConfig,
saveDailyInventoryNotificationState,
};

View File

@ -0,0 +1,217 @@
'use strict';
// 每日进销存 · 页面截图与上传
//
// 要点:
// 1. 使用服务端签发的**短时效** JWT 作为专用身份访问页面(15 分钟过期),
// 令牌只写入浏览器 localStorage,不会出现在截图地址、日志或接口响应里。
// 2. 截图前必须等到数据真正加载完成:表格有数据行、没有 Spin 加载中、没有错误提示。
// 3. 表格带内部滚动,直接截屏只能看到一屏;截图前注入 CSS 展开滚动容器,
// 再整页截图,保证数据是完整的。
// 4. 截图落在系统临时目录,上传成功后立即删除。
const fs = require('fs');
const os = require('os');
const path = require('path');
const jwt = require('jsonwebtoken');
const { uploadFileToDefaultS3 } = require('./objectStorage');
const TIMEZONE = 'Asia/Shanghai';
const PAGE_PATH = '/planning/operation/daily-inventory';
// 生产主站(见《服务器部署规范》):可用 DAILY_INVENTORY_SCREENSHOT_BASE_URL / PUBLIC_SITE_URL 覆盖
const DEFAULT_BASE_URL = 'https://ck.yydd.ai';
// 页面首次加载要扫订单明细,约 15~20 秒,等待上限给到 3 分钟
const PAGE_READY_TIMEOUT_MS = 3 * 60 * 1000;
const SCREENSHOT_SERVICE_USER = Object.freeze({
sub: 'daily-inventory-screenshot-bot',
username: 'daily_inventory_screenshot_bot',
name: '每日进销存截图服务',
role: 'admin',
authSource: 'service',
allowedPageKeys: [],
});
const EXPAND_CSS = `
.ant-table-body, .ant-table-content, .ant-table-container { max-height: none !important; overflow: visible !important; }
.daily-inventory-page { overflow: visible !important; }
.ant-table-cell-fix-left, .ant-table-cell-fix-right { position: static !important; }
`;
function normalizeText(value, maxLength = 500) {
return String(value ?? '').trim().slice(0, maxLength);
}
/** 北京时间日期/时间片段:YYYY-MM-DD、YYYYMMDD、HHmmss */
function chinaTimeParts(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
const dateText = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
}).format(target);
const timeText = new Intl.DateTimeFormat('en-GB', {
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(target);
return {
bizDate: dateText,
dateKey: dateText.replace(/\D/g, ''),
timeKey: timeText.replace(/\D/g, ''),
};
}
function resolveBaseUrl() {
const raw = normalizeText(
process.env.DAILY_INVENTORY_SCREENSHOT_BASE_URL
|| process.env.PUBLIC_SITE_URL
|| DEFAULT_BASE_URL,
500
).replace(/\/+$/, '');
return raw || DEFAULT_BASE_URL;
}
function resolvePageUrl() {
return `${resolveBaseUrl()}${PAGE_PATH}`;
}
/**
* 签发截图专用短时效令牌。
* 只包含页面鉴权必需的最小声明,不含任何用户凭据或密钥。
*/
function signScreenshotToken() {
const secret = process.env.CUBEJS_SECRET;
if (!secret) throw new Error('缺少 CUBEJS_SECRET,无法签发截图文身份令牌');
return jwt.sign({ ...SCREENSHOT_SERVICE_USER }, secret, { expiresIn: '15m' });
}
/** 截图存放的临时文件(不落业务目录,上传后立即删除) */
function buildTempFilePath(bizDate, dateKey, timeKey) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-'));
return {
dir,
filePath: path.join(dir, `daily-inventory-${dateKey}-${timeKey}.png`),
objectKey: `daily-inventory/${bizDate}/daily-inventory-${dateKey}-${timeKey}.png`,
};
}
async function launchBrowser() {
let playwright;
try {
playwright = require('playwright');
} catch (_error) {
throw new Error('未安装 playwright,无法执行页面截图');
}
try {
return await playwright.chromium.launch({
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
} catch (error) {
const detail = normalizeText(error?.message, 300);
throw new Error(`浏览器启动失败(服务器需先执行 npx playwright install chromium):${detail}`);
}
}
/** 等到数据真正渲染完成:有数据行、无加载中、无错误提示 */
async function waitForPageReady(page) {
await page.waitForSelector('.daily-inventory-table .ant-table-tbody tr', {
timeout: PAGE_READY_TIMEOUT_MS,
state: 'attached',
});
await page.waitForFunction(
() => {
const spinning = document.querySelector('.daily-inventory-page .ant-spin-spinning');
const skeleton = document.querySelector('.ant-skeleton');
const rows = document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr');
return !spinning && !skeleton && rows.length > 0;
},
{ timeout: PAGE_READY_TIMEOUT_MS, polling: 500 }
);
const errorText = await page.evaluate(() => {
const node = document.querySelector('.daily-inventory-page .ant-alert-error');
return node ? node.textContent || '' : '';
});
if (errorText) {
throw new Error(`页面返回错误提示,放弃截图:${normalizeText(errorText, 200)}`);
}
const rowCount = await page.evaluate(
() => document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr').length
);
if (!rowCount) throw new Error('页面未渲染出任何数据行,放弃截图');
return rowCount;
}
/**
* 执行一次截图并上传到默认对象存储桶
* @returns {Promise<{url:string, key:string, bizDate:string, size:number, rowCount:number, takenAt:string}>}
*/
async function captureAndUploadDailyInventory(options = {}) {
const now = options.now ? new Date(options.now) : new Date();
const { bizDate, dateKey, timeKey } = chinaTimeParts(now);
const token = signScreenshotToken();
const pageUrl = resolvePageUrl();
const browser = await launchBrowser();
const { dir, filePath, objectKey } = buildTempFilePath(bizDate, dateKey, timeKey);
let rowCount = 0;
try {
const context = await browser.newContext({
viewport: { width: 1680, height: 1050 },
deviceScaleFactor: 1,
timezoneId: TIMEZONE,
locale: 'zh-CN',
});
// 令牌只注入浏览器 localStorage,URL 与日志里都不出现
await context.addInitScript((value) => {
try {
window.localStorage.setItem('token', value);
} catch (_error) {
// 忽略:首个 document 可能还不允许访问 localStorage
}
}, token);
const page = await context.newPage();
page.on('pageerror', () => {});
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: PAGE_READY_TIMEOUT_MS });
rowCount = await waitForPageReady(page);
// 展开内部滚动容器,保证整页截图包含完整数据区
await page.addStyleTag({ content: EXPAND_CSS });
await page.waitForTimeout(500);
await page.screenshot({ path: filePath, fullPage: true });
const stats = await fs.promises.stat(filePath);
if (!stats.size) throw new Error('截图文件为空');
const uploaded = await uploadFileToDefaultS3(filePath, {
key: objectKey,
mimeType: 'image/png',
folder: 'daily-inventory',
extension: 'png',
});
return {
url: uploaded.url,
key: uploaded.key,
bizDate,
size: stats.size,
rowCount,
takenAt: now.toISOString(),
};
} finally {
await browser.close().catch(() => {});
fs.rm(dir, { recursive: true, force: true }, () => {});
}
}
module.exports = {
DEFAULT_BASE_URL,
PAGE_PATH,
PAGE_READY_TIMEOUT_MS,
TIMEZONE,
captureAndUploadDailyInventory,
chinaTimeParts,
resolveBaseUrl,
resolvePageUrl,
signScreenshotToken,
};

View File

@ -0,0 +1,166 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
DEFAULT_NOTIFY_TIME,
MAX_RECIPIENTS,
buildIdempotencyKey,
normalizeFlag,
normalizeNotifyTime,
normalizeRecipients,
} = require('../src/services/dailyInventoryNotificationSettings');
const {
ACTION_TITLE,
NOTIFICATION_TITLE,
buildNotificationContent,
minutesOfDay,
resolveBizDate,
withRetry,
} = require('../src/jobs/dailyInventoryNotificationJob');
const { chinaTimeParts } = require('../src/services/dailyInventoryScreenshot');
// ── 收件人名单规范化 ────────────────────────────────────────────
test('normalizeRecipients 去空值、去空白、保持顺序', () => {
assert.deepEqual(normalizeRecipients(['张三', ' ', '李四', '', null, undefined]), ['张三', '李四']);
});
test('normalizeRecipients 去重且忽略首尾空格', () => {
assert.deepEqual(normalizeRecipients([' 张三 ', '张三', '李四', ' 李四']), ['张三', '李四']);
});
test('normalizeRecipients 支持逗号/中文逗号/分号/换行分隔的字符串', () => {
assert.deepEqual(
normalizeRecipients('张三,李四,王五;赵六\n孙七'),
['张三', '李四', '王五', '赵六', '孙七']
);
});
test('normalizeRecipients 对非法输入返回空数组', () => {
assert.deepEqual(normalizeRecipients(null), []);
assert.deepEqual(normalizeRecipients(undefined), []);
assert.deepEqual(normalizeRecipients(12345), []);
});
test('normalizeRecipients 超量时截断到上限', () => {
const list = Array.from({ length: MAX_RECIPIENTS + 5 }, (_, i) => `用户${i}`);
const result = normalizeRecipients(list);
assert.equal(result.length, MAX_RECIPIENTS);
assert.equal(result[0], '用户0');
});
// ── 通知时间校验 ────────────────────────────────────────────────
test('normalizeNotifyTime 接受合法 HH:mm 并补零', () => {
assert.equal(normalizeNotifyTime('1:05'), '01:05');
assert.equal(normalizeNotifyTime('23:59'), '23:59');
assert.equal(normalizeNotifyTime('00:00'), '00:00');
});
test('normalizeNotifyTime 支持 HH:mm:ss 并截断到分钟', () => {
assert.equal(normalizeNotifyTime('01:00:30'), '01:00');
});
test('normalizeNotifyTime 非法值回退默认 01:00', () => {
assert.equal(normalizeNotifyTime('24:00'), DEFAULT_NOTIFY_TIME);
assert.equal(normalizeNotifyTime('12:60'), DEFAULT_NOTIFY_TIME);
assert.equal(normalizeNotifyTime('abc'), DEFAULT_NOTIFY_TIME);
assert.equal(normalizeNotifyTime(''), DEFAULT_NOTIFY_TIME);
assert.equal(normalizeNotifyTime(null), DEFAULT_NOTIFY_TIME);
});
test('normalizeFlag 只接受明确的布尔语义,未知值回退默认', () => {
assert.equal(normalizeFlag('true', false), true);
assert.equal(normalizeFlag('off', true), false);
assert.equal(normalizeFlag('maybe', true), true);
assert.equal(normalizeFlag(null, false), false);
});
// ── 幂等键 ──────────────────────────────────────────────────────
test('buildIdempotencyKey 正式推送按业务日期+收件人稳定生成', () => {
const a = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
const b = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
const c = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '李四', mode: 'scheduled' });
assert.equal(a, b);
assert.notEqual(a, c);
});
test('buildIdempotencyKey 测试推送带随机数,不占用正式幂等记录', () => {
const formal = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
const t1 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n1' });
const t2 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n2' });
assert.notEqual(t1, t2);
assert.notEqual(t1, formal);
});
// ── 通知正文 ────────────────────────────────────────────────────
test('buildNotificationContent 包含数据日期、生成时间、图片地址与页面链接', () => {
const content = buildNotificationContent({
bizDate: '2026-09-25',
takenAt: '2026/09/25 01:00:00',
imageUrl: 'https://cdn.example.com/daily-inventory/2026-09-25/daily-inventory-20260925-010000.png',
pageUrl: 'https://ck.yydd.ai/planning/operation/daily-inventory',
});
assert.match(content, /2026-09-25/);
assert.match(content, /2026\/09\/25 01:00:00/);
assert.match(content, /https:\/\/cdn\.example\.com\//);
assert.match(content, /https:\/\/ck\.yydd\.ai\/planning\/operation\/daily-inventory/);
assert.match(content, new RegExp(ACTION_TITLE));
assert.equal(NOTIFICATION_TITLE, '每日进销存数据');
});
// ── 业务日期与调度时刻 ──────────────────────────────────────────
test('resolveBizDate 取北京时间昨日(与页面 T-1 口径一致)', () => {
assert.equal(resolveBizDate(new Date('2026-09-25T01:00:00+08:00')), '2026-09-24');
assert.equal(resolveBizDate(new Date('2026-09-25T23:30:00+08:00')), '2026-09-24');
});
test('minutesOfDay 正确换算,非法值返回 null', () => {
assert.equal(minutesOfDay('00:00'), 0);
assert.equal(minutesOfDay('01:00'), 60);
assert.equal(minutesOfDay('23:59'), 1439);
assert.equal(minutesOfDay('24:00'), null);
assert.equal(minutesOfDay('abc'), null);
assert.equal(minutesOfDay(null), null);
});
test('chinaTimeParts 生成对象键所需的日期与时间片段', () => {
const parts = chinaTimeParts(new Date('2026-09-25T01:00:00+08:00'));
assert.equal(parts.bizDate, '2026-09-25');
assert.equal(parts.dateKey, '20260925');
assert.equal(parts.timeKey, '010000');
assert.equal(
`daily-inventory/${parts.bizDate}/daily-inventory-${parts.dateKey}-${parts.timeKey}.png`,
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
);
});
// ── 重试与退避 ──────────────────────────────────────────────────
test('withRetry 第二次成功后返回结果', async () => {
let calls = 0;
const result = await withRetry(
async () => {
calls += 1;
if (calls < 2) throw new Error('临时失败');
return 'ok';
},
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
);
assert.equal(result, 'ok');
assert.equal(calls, 2);
});
test('withRetry 超过次数后抛出最后一次错误', async () => {
let calls = 0;
await assert.rejects(
withRetry(
async () => {
calls += 1;
throw new Error(`失败${calls}`);
},
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
),
/失败3/
);
assert.equal(calls, 3);
});

View File

@ -0,0 +1,17 @@
import request from './request';
// 每日进销存 · 定时截图通知(管理员专用)
// 后端只返回「是否已配置」与脱敏预览,不返回任何密钥
export const getDailyInventoryNotificationConfig = () =>
request.get('/api/daily-inventory-notification/config');
export const saveDailyInventoryNotificationConfig = (payload) =>
request.put('/api/daily-inventory-notification/config', payload);
/** 立即测试:真实截图 + 上传 + 通知一次,不影响正式定时任务的幂等记录 */
export const runDailyInventoryNotificationTest = () =>
request.post('/api/daily-inventory-notification/test', {}, { timeout: 300000 });
export const getDailyInventoryNotificationRuns = (limit = 10) =>
request.get('/api/daily-inventory-notification/runs', { params: { limit } });

View File

@ -1,8 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd';
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import {
Alert, Button, Input, Modal, Space, Spin, Switch, Table, Tag, TimePicker, Tooltip, Typography,
} from 'antd';
import { BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmPage } from '../../components/Common';
import { getDailyInventory } from '../../api/dailyInventory';
import {
getDailyInventoryNotificationConfig,
runDailyInventoryNotificationTest,
saveDailyInventoryNotificationConfig,
} from '../../api/dailyInventoryNotification.js';
import {
DAILY_INVENTORY_COLUMNS,
COLUMN_SOURCE,
@ -12,6 +20,34 @@ import './style.css';
const { Title, Text } = Typography;
const TIME_FORMAT = 'HH:mm';
/** 当前登录用户是否为管理员(决定「通知设置」入口是否可见) */
function readIsAdmin() {
try {
const stored = JSON.parse(localStorage.getItem('auth_user') || 'null');
if (stored?.role) return stored.role === 'admin';
const token = localStorage.getItem('token') || '';
const payload = JSON.parse(window.atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return payload?.role === 'admin';
} catch {
return false;
}
}
/** 收件人名单规范化:去空、去重、限长(后端会再校验一次) */
function normalizeRecipientList(list = []) {
const seen = new Set();
const result = [];
for (const raw of list) {
const name = String(raw ?? '').trim().slice(0, 50);
if (!name || seen.has(name)) continue;
seen.add(name);
result.push(name);
}
return result;
}
const TipTitle = ({ tip, children }) => (
<Tooltip title={tip}>
<span className="daily-inventory-th-tip">{children}</span>
@ -45,6 +81,17 @@ const DailyInventory = () => {
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
// ── 定时截图通知设置 ──
const isAdmin = useMemo(readIsAdmin, []);
const [notifyOpen, setNotifyOpen] = useState(false);
const [notifyLoading, setNotifyLoading] = useState(false);
const [notifySaving, setNotifySaving] = useState(false);
const [notifyTesting, setNotifyTesting] = useState(false);
const [notifyConfig, setNotifyConfig] = useState(null);
const [notifyResult, setNotifyResult] = useState(null);
const [recipientDraft, setRecipientDraft] = useState('');
const [notifyForm, setNotifyForm] = useState({ enabled: false, notifyTime: '01:00', recipients: [] });
const load = useCallback(async (force = false) => {
setLoading(true);
setError('');
@ -98,6 +145,98 @@ const DailyInventory = () => {
const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]);
// ── 通知设置:读取 / 保存 / 立即测试 ──
const applyNotifyConfig = useCallback((config = {}) => {
setNotifyConfig(config);
setNotifyForm({
enabled: config.enabled === true,
notifyTime: config.notifyTime || '01:00',
recipients: normalizeRecipientList(config.recipients || []),
});
}, []);
const loadNotifyConfig = useCallback(async () => {
setNotifyLoading(true);
try {
const res = await getDailyInventoryNotificationConfig();
applyNotifyConfig(res?.data ?? {});
} catch (err) {
setNotifyConfig({ loadError: err?.response?.data?.error || err?.message || '加载通知设置失败' });
} finally {
setNotifyLoading(false);
}
}, [applyNotifyConfig]);
useEffect(() => {
if (notifyOpen) {
setNotifyResult(null);
loadNotifyConfig();
}
}, [notifyOpen, loadNotifyConfig]);
const saveNotifySettings = useCallback(async () => {
setNotifySaving(true);
setNotifyResult(null);
try {
const res = await saveDailyInventoryNotificationConfig({
enabled: notifyForm.enabled,
notifyTime: notifyForm.notifyTime,
recipients: normalizeRecipientList(notifyForm.recipients),
});
applyNotifyConfig(res?.data?.admin ?? res?.data ?? {});
setNotifyResult({ type: 'success', text: '通知设置已保存' });
} catch (err) {
setNotifyResult({
type: 'error',
text: err?.response?.data?.error || err?.message || '保存通知设置失败',
});
} finally {
setNotifySaving(false);
}
}, [notifyForm, applyNotifyConfig]);
const runNotifyTest = useCallback(async () => {
setNotifyTesting(true);
setNotifyResult(null);
try {
const res = await runDailyInventoryNotificationTest();
const result = res?.data ?? {};
if (result.success) {
setNotifyResult({
type: 'success',
text: `测试完成:截图已上传,成功通知 ${(result.recipients || []).filter((r) => r.status === 'sent').length} 人`,
url: result.screenshotUrl,
});
} else {
setNotifyResult({ type: 'error', text: `测试未成功:${result.error || result.reason || '未知原因'}` });
}
loadNotifyConfig();
} catch (err) {
setNotifyResult({
type: 'error',
text: err?.response?.data?.error || err?.message || '执行测试失败',
});
} finally {
setNotifyTesting(false);
}
}, [loadNotifyConfig]);
const addRecipients = useCallback(() => {
const incoming = normalizeRecipientList(String(recipientDraft || '').split(/[,,;;\n]+/));
if (!incoming.length) return;
setNotifyForm((old) => ({
...old,
recipients: normalizeRecipientList([...(old.recipients || []), ...incoming]),
}));
setRecipientDraft('');
}, [recipientDraft]);
const removeRecipient = useCallback((name) => {
setNotifyForm((old) => ({
...old,
recipients: (old.recipients || []).filter((item) => item !== name),
}));
}, []);
return (
<SycmPage className="daily-inventory-page">
@ -129,6 +268,9 @@ const DailyInventory = () => {
) : null}
</div>
<Space>
{isAdmin ? (
<Button icon={<BellOutlined />} onClick={() => setNotifyOpen(true)}>通知设置</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => load(false)} loading={loading}>刷新</Button>
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细重算周转率与分级(约 15~20 秒)">
<Button onClick={() => load(true)} loading={loading}>强制重算</Button>
@ -172,6 +314,159 @@ const DailyInventory = () => {
共 {items.length} 个仓库;蓝色底纹列为运行时实时取数,灰色列为暂无单据来源,其余为本地快照表取值。
</Text>
<Modal
title="定时截图通知"
open={notifyOpen}
onCancel={() => setNotifyOpen(false)}
width={720}
destroyOnHidden
styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }}
footer={(
<Space wrap>
<Button type="primary" loading={notifySaving} disabled={notifyLoading} onClick={saveNotifySettings}>
保存设置
</Button>
<Tooltip title="真实执行一次截图、上传与通知;测试不会占用正式推送的幂等记录">
<Button loading={notifyTesting} disabled={notifyLoading} onClick={runNotifyTest}>
立即测试
</Button>
</Tooltip>
<Button onClick={() => setNotifyOpen(false)}>关闭</Button>
</Space>
)}
>
<Spin spinning={notifyLoading}>
{notifyConfig?.loadError ? (
<Alert type="error" showIcon message={notifyConfig.loadError} style={{ marginBottom: 12 }} />
) : null}
{notifyConfig?.blockingReasons?.length ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="当前不会自动推送,原因如下"
description={<span>{notifyConfig.blockingReasons.join(';')}</span>}
/>
) : null}
<div className="daily-inventory-notify-form">
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">启用每日截图通知</span>
<Switch
checked={notifyForm.enabled}
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
/>
</div>
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">通知时间</span>
<TimePicker
format={TIME_FORMAT}
minuteStep={5}
allowClear={false}
value={dayjs(`2026-01-01T${notifyForm.notifyTime}:00`)}
onChange={(value) => setNotifyForm((old) => ({
...old,
notifyTime: value ? value.format(TIME_FORMAT) : '01:00',
}))}
style={{ width: 140 }}
/>
<Text type="secondary">({notifyConfig?.timezone || 'Asia/Shanghai'},默认 01:00)</Text>
</div>
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">通知用户</span>
<Input
allowClear
placeholder="输入通知中心中的姓名或别名,回车添加;多个可用逗号分隔"
value={recipientDraft}
onChange={(e) => setRecipientDraft(e.target.value)}
onPressEnter={addRecipients}
style={{ width: 320 }}
/>
<Button icon={<PlusOutlined />} onClick={addRecipients} disabled={!String(recipientDraft || '').trim()}>
添加
</Button>
</div>
<div className="daily-inventory-notify-recipients">
{notifyForm.recipients.length ? (
notifyForm.recipients.map((name) => (
<Tag key={name} closable onClose={() => removeRecipient(name)}>{name}</Tag>
))
) : (
<Text type="secondary">尚未添加通知用户</Text>
)}
</div>
<Text type="secondary">
自动去除空值与重复姓名,最多 {notifyConfig?.maxRecipients || 20} 人。
</Text>
</div>
{notifyResult ? (
<Alert
type={notifyResult.type}
showIcon
style={{ marginTop: 12 }}
message={notifyResult.text}
description={notifyResult.url ? (
<a href={notifyResult.url} target="_blank" rel="noreferrer">{notifyResult.url}</a>
) : null}
/>
) : null}
{notifyConfig && !notifyConfig.loadError ? (
<div className="daily-inventory-notify-meta">
<div>
统一通知中心:
<Text type={notifyConfig.notificationCenter?.configured ? 'success' : 'warning'} strong>
{notifyConfig.notificationCenter?.configured ? '已配置' : '未配置'}
</Text>
{notifyConfig.notificationCenter?.baseUrl ? (
<Text type="secondary">{` ${notifyConfig.notificationCenter.baseUrl}`}</Text>
) : null}
</div>
<div>
对象存储:
<Text type={notifyConfig.objectStorage?.configured ? 'success' : 'warning'} strong>
{notifyConfig.objectStorage?.configured ? '已配置' : '未配置'}
</Text>
{notifyConfig.objectStorage?.bucket ? (
<Text type="secondary">{` 桶 ${notifyConfig.objectStorage.bucket}`}</Text>
) : null}
</div>
<div>
上次执行:
<b>{notifyConfig.lastRunAt ? new Date(notifyConfig.lastRunAt).toLocaleString('zh-CN') : '—'}</b>
{notifyConfig.lastRunSuccess === null ? '' : (
<Text type={notifyConfig.lastRunSuccess ? 'success' : 'danger'}>
{` ${notifyConfig.lastRunSuccess ? '成功' : '失败'}`}
</Text>
)}
</div>
</div>
) : null}
{notifyConfig?.runs?.length ? (
<div className="daily-inventory-notify-runs">
<Text strong>最近执行记录</Text>
{notifyConfig.runs.slice(0, 5).map((run, index) => (
<div key={`${run.runAt}-${index}`} className="daily-inventory-notify-run">
<span>{new Date(run.runAt).toLocaleString('zh-CN')}</span>
<Text type={run.success ? 'success' : 'danger'}>
{run.success ? '成功' : `失败(${run.error || run.reason || '未知原因'})`}
</Text>
{run.screenshotUrl ? (
<a href={run.screenshotUrl} target="_blank" rel="noreferrer">截图</a>
) : null}
</div>
))}
</div>
) : null}
</Spin>
</Modal>
<Modal
title="计算口径说明"
open={helpOpen}

View File

@ -83,6 +83,59 @@
background: rgba(0, 0, 0, 0.03);
}
/* 定时截图通知设置弹窗 */
.daily-inventory-notify-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.daily-inventory-notify-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.daily-inventory-notify-label {
width: 120px;
flex: none;
color: rgba(0, 0, 0, 0.75);
}
.daily-inventory-notify-recipients {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-left: 128px;
min-height: 24px;
}
.daily-inventory-notify-meta {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.daily-inventory-notify-runs {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.daily-inventory-notify-run {
display: flex;
align-items: center;
gap: 10px;
color: rgba(0, 0, 0, 0.6);
}
.daily-inventory-footer {
font-size: 12px;
}