fix: 每日进销存截图按业务日期归档,并补充幂等与执行记录测试 #47
@ -189,7 +189,8 @@ async function runDailyInventoryNotificationJob(options = {}) {
|
||||
let shot;
|
||||
try {
|
||||
shot = await withRetry(
|
||||
() => captureAndUploadDailyInventory({ now }),
|
||||
// 对象键按业务日期归档:daily-inventory/2026-09-25/daily-inventory-20260925-010000.png
|
||||
() => captureAndUploadDailyInventory({ now, bizDate }),
|
||||
{ label: '截图并上传' }
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@ -152,16 +152,28 @@ async function signScreenshotToken() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 截图存放的临时文件(不落业务目录,上传后立即删除) */
|
||||
function buildTempFilePath(bizDate, dateKey, timeKey) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-'));
|
||||
/**
|
||||
* 截图文件名与对象键。
|
||||
* 目录与文件名里的日期都用**业务日期**,时间用执行时刻,便于按数据日期归档:
|
||||
* daily-inventory/2026-09-25/daily-inventory-20260925-010000.png
|
||||
*/
|
||||
function buildScreenshotNames({ bizDate, timeKey }) {
|
||||
const safeDate = normalizeText(bizDate, 10);
|
||||
const dateKey = safeDate.replace(/\D/g, '');
|
||||
const fileName = `daily-inventory-${dateKey}-${timeKey}.png`;
|
||||
return {
|
||||
dir,
|
||||
filePath: path.join(dir, `daily-inventory-${dateKey}-${timeKey}.png`),
|
||||
objectKey: `daily-inventory/${bizDate}/daily-inventory-${dateKey}-${timeKey}.png`,
|
||||
fileName,
|
||||
objectKey: `daily-inventory/${safeDate}/${fileName}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 截图存放的临时文件(不落业务目录,上传后立即删除) */
|
||||
function buildTempFilePath({ bizDate, timeKey }) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-'));
|
||||
const { fileName, objectKey } = buildScreenshotNames({ bizDate, timeKey });
|
||||
return { dir, filePath: path.join(dir, fileName), objectKey };
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let playwright;
|
||||
try {
|
||||
@ -217,12 +229,14 @@ async function waitForPageReady(page) {
|
||||
*/
|
||||
async function captureAndUploadDailyInventory(options = {}) {
|
||||
const now = options.now ? new Date(options.now) : new Date();
|
||||
const { bizDate, dateKey, timeKey } = chinaTimeParts(now);
|
||||
const { timeKey } = chinaTimeParts(now);
|
||||
// 业务日期由调用方(定时任务)给出;未指定时按北京时间当天兜底
|
||||
const bizDate = normalizeText(options.bizDate, 10) || chinaTimeParts(now).bizDate;
|
||||
const token = await signScreenshotToken();
|
||||
const pageUrl = resolvePageUrl();
|
||||
|
||||
const browser = await launchBrowser();
|
||||
const { dir, filePath, objectKey } = buildTempFilePath(bizDate, dateKey, timeKey);
|
||||
const { dir, filePath, objectKey } = buildTempFilePath({ bizDate, timeKey });
|
||||
let rowCount = 0;
|
||||
|
||||
try {
|
||||
@ -282,6 +296,7 @@ module.exports = {
|
||||
SERVICE_NAME,
|
||||
SERVICE_USERNAME,
|
||||
TIMEZONE,
|
||||
buildScreenshotNames,
|
||||
captureAndUploadDailyInventory,
|
||||
chinaTimeParts,
|
||||
ensureScreenshotServiceUser,
|
||||
|
||||
@ -19,7 +19,10 @@ const {
|
||||
resolveBizDate,
|
||||
withRetry,
|
||||
} = require('../src/jobs/dailyInventoryNotificationJob');
|
||||
const { chinaTimeParts } = require('../src/services/dailyInventoryScreenshot');
|
||||
const {
|
||||
buildScreenshotNames,
|
||||
chinaTimeParts,
|
||||
} = require('../src/services/dailyInventoryScreenshot');
|
||||
|
||||
// ── 收件人名单规范化 ────────────────────────────────────────────
|
||||
test('normalizeRecipients 去空值、去空白、保持顺序', () => {
|
||||
@ -135,6 +138,95 @@ test('chinaTimeParts 生成对象键所需的日期与时间片段', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScreenshotNames 用业务日期归档目录与文件名,时间取执行时刻', () => {
|
||||
const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' });
|
||||
assert.equal(names.fileName, 'daily-inventory-20260925-010000.png');
|
||||
assert.equal(
|
||||
names.objectKey,
|
||||
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScreenshotNames 业务日期与执行日期不同时以业务日期为准', () => {
|
||||
// 凌晨 01:00 推送的是「昨日」数据,归档必须落在业务日而不是执行日
|
||||
const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' });
|
||||
assert.ok(!names.objectKey.includes('2026-09-26'));
|
||||
assert.ok(names.objectKey.startsWith('daily-inventory/2026-09-25/'));
|
||||
});
|
||||
|
||||
test('buildScreenshotNames 业务日期缺失时回退空目录占位而不抛错', () => {
|
||||
const names = buildScreenshotNames({ bizDate: '', timeKey: '010000' });
|
||||
assert.equal(names.fileName, 'daily-inventory--010000.png');
|
||||
});
|
||||
|
||||
// ── 幂等记录:同一业务日期 + 同一用户只成功通知一次 ────────────
|
||||
async function withStubbedState(runner) {
|
||||
const database = require('../src/config/database');
|
||||
const settings = require('../src/services/dailyInventoryNotificationSettings');
|
||||
const original = database.local;
|
||||
const store = new Map();
|
||||
|
||||
database.local = {
|
||||
async query(sql, params) {
|
||||
const text = String(sql);
|
||||
if (text.startsWith('SELECT config_value')) {
|
||||
const value = store.get(params[0]);
|
||||
return value === undefined ? [] : [{ configValue: value }];
|
||||
}
|
||||
if (text.startsWith('SELECT id FROM system_config')) {
|
||||
const value = store.get(params[0]);
|
||||
return value === undefined ? [] : [{ id: 1 }];
|
||||
}
|
||||
// UPDATE system_config SET config_value = ? ...
|
||||
store.set(params[params.length - 1], params[0]);
|
||||
return { affectedRows: 1 };
|
||||
},
|
||||
async insert(_table, data) {
|
||||
store.set(data.config_key, data.config_value);
|
||||
return { insertId: 1 };
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await runner(settings);
|
||||
} finally {
|
||||
database.local = original;
|
||||
}
|
||||
}
|
||||
|
||||
test('markNotified 后同一业务日期同一用户命中幂等,跨日期互不影响', async () => {
|
||||
await withStubbedState(async (settings) => {
|
||||
assert.equal(await settings.hasNotified('2026-09-25', '苏适'), false);
|
||||
await settings.markNotified('2026-09-25', '苏适', { deliveryId: 'd-1' });
|
||||
assert.equal(await settings.hasNotified('2026-09-25', '苏适'), true);
|
||||
// 同一天其他用户不受影响
|
||||
assert.equal(await settings.hasNotified('2026-09-25', '李四'), false);
|
||||
// 同一用户换一天应当重新通知
|
||||
assert.equal(await settings.hasNotified('2026-09-26', '苏适'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('appendRunRecord 保留执行时间、截图地址、通知对象与失败原因', async () => {
|
||||
await withStubbedState(async (settings) => {
|
||||
await settings.appendRunRecord({
|
||||
runAt: '2026-09-25T01:00:00.000Z',
|
||||
bizDate: '2026-09-25',
|
||||
mode: 'scheduled',
|
||||
success: false,
|
||||
error: '所有收件人均通知失败',
|
||||
screenshotUrl: 'https://scqny.jiansun.vip/daily-inventory/2026-09-25/x.png',
|
||||
recipients: [{ recipient: '苏适', status: 'failed', error: '通知中心 502' }],
|
||||
});
|
||||
const runs = await settings.listRunRecords(5);
|
||||
assert.equal(runs.length, 1);
|
||||
assert.equal(runs[0].bizDate, '2026-09-25');
|
||||
assert.equal(runs[0].success, false);
|
||||
assert.equal(runs[0].error, '所有收件人均通知失败');
|
||||
assert.equal(runs[0].recipients[0].recipient, '苏适');
|
||||
assert.ok(runs[0].screenshotUrl.startsWith('https://'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── 重试与退避 ──────────────────────────────────────────────────
|
||||
test('withRetry 第二次成功后返回结果', async () => {
|
||||
let calls = 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user