cangku/backend/src/jobs/goodsFirstSeenJob.js
yanhui 445347e664 feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。

主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
   - retiredFeatures 移出对应退役名单与清表语句,改为正常保留
   - 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
   - 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
   - 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
     当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
   - MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
   name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
   相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
   parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
   而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
   真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)

验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
  Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
  与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
  会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
  --ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致

未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
2026-09-15 16:53:12 +08:00

165 lines
7.1 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.

const cron = require('node-cron');
const jianSunDb = require('../config/jianSunDatabase');
const planningDb = require('../services/planningDatabase');
const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService');
const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim();
const TIMEZONE = 'Asia/Shanghai';
const SYNC_CHUNK_SIZE = 500;
const BACKFILL_CHUNK_SIZE = 800;
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
let schemaEnsured = false;
async function ensureTable() {
if (schemaEnsured) return;
await planningDb.query(`
CREATE TABLE IF NOT EXISTS pdd_goods_first_seen (
goods_id VARCHAR(50) NOT NULL PRIMARY KEY,
first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)',
last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期',
is_onsale TINYINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
schemaEnsured = true;
}
function epochToDatetime(value) {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) return null;
const ms = num >= 1e12 ? num : num * 1000;
const date = new Date(ms);
return Number.isNaN(date.getTime()) ? null : date;
}
// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at)
async function syncCaptureDate(captureDate) {
await ensureTable();
const startedAt = Date.now();
const rows = await jianSunDb.query(
`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId,
MIN(s.createdAt) AS minCreatedAt,
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index))
WHERE s.createTime = @captureDate
AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> ''
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`,
{ captureDate },
{ requestTimeout: 300000 }
);
const records = [];
rows.forEach((row) => {
const createdAt = epochToDatetime(row.minCreatedAt);
if (!createdAt) return;
records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]);
});
const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) {
const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE);
await planningDb.query(
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
VALUES ?
ON DUPLICATE KEY UPDATE
last_capture_date = VALUES(last_capture_date),
is_onsale = VALUES(is_onsale)`,
[chunk]
);
}
const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
return {
captureDate,
goodsInBatch: records.length,
upserted: records.length,
newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)),
total: Number(countAfter[0]?.n || 0),
elapsedMs: Date.now() - startedAt,
};
}
// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验)
async function refreshLatestCapture() {
const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10));
if (!captureDate) {
console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步');
return { skipped: true };
}
return syncCaptureDate(captureDate);
}
// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制)
async function backfillAllHistory() {
await ensureTable();
const startedAt = Date.now();
const goodsRows = await planningDb.query(
`SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})`
);
const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))];
console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`);
const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
let synced = 0;
for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) {
const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE);
const params = {};
const placeholders = chunk.map((goodsId, index) => {
params[`g${index}`] = Number(goodsId);
return `@g${index}`;
}).join(', ');
const rows = await jianSunDb.query(
`SELECT s.goodsId AS goodsId,
MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt,
MAX(s.createTime) AS lastCaptureRaw,
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus))
WHERE s.goodsId IN (${placeholders})
GROUP BY s.goodsId`,
params,
{ requestTimeout: 300000 }
);
const records = [];
rows.forEach((row) => {
const createdAt = epochToDatetime(row.minCreatedAt);
const lastCapture = epochToDatetime(row.lastCaptureRaw);
if (!createdAt) return;
records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]);
});
for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) {
const chunk = records.slice(i, i + SYNC_CHUNK_SIZE);
await planningDb.query(
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
VALUES ?
ON DUPLICATE KEY UPDATE
last_capture_date = VALUES(last_capture_date),
is_onsale = VALUES(is_onsale)`,
[chunk]
);
}
synced += records.length;
console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`);
}
const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt };
console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`);
return summary;
}
function startGoodsFirstSeenScheduler() {
const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true';
if (disabled) {
console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用');
return;
}
cron.schedule(JOB_CRON, () => {
refreshLatestCapture().catch((error) => {
console.error('[GoodsFirstSeen] 定时同步失败:', error.message);
});
}, { timezone: TIMEZONE });
console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`);
}
module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler };