本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
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 排除。
431 lines
24 KiB
JavaScript
431 lines
24 KiB
JavaScript
const dayjs = require('dayjs');
|
||
const database = require('../config/database');
|
||
const jianSunDb = require('../config/jianSunDatabase');
|
||
const cloudDatabase = require('../config/cloudDatabase');
|
||
|
||
const DEFAULT_SCOPE_START = '2026-07-15';
|
||
const DEFAULT_SCOPE_END = '2026-07-21';
|
||
const DEFAULT_END_DATE = '2026-09-02';
|
||
const AWARD_PER_ID = 400;
|
||
const JULY_START = '2026-07-01';
|
||
const JULY_END = '2026-07-31';
|
||
|
||
function text(value, fallback = '') {
|
||
const valueText = String(value ?? '').trim();
|
||
return valueText || fallback;
|
||
}
|
||
|
||
function validDate(value, fallback) {
|
||
const valueText = text(value, fallback);
|
||
return /^\d{4}-\d{2}-\d{2}$/u.test(valueText) && dayjs(valueText).isValid() ? valueText : fallback;
|
||
}
|
||
|
||
function addDays(value, offset) {
|
||
return dayjs(value).add(offset, 'day').format('YYYY-MM-DD');
|
||
}
|
||
|
||
function resolveQuery(query = {}) {
|
||
const scopeStart = validDate(query.scopeStart || query.startDate, DEFAULT_SCOPE_START);
|
||
const scopeEnd = validDate(query.scopeEnd || query.endDate, DEFAULT_SCOPE_END);
|
||
const endDate = validDate(query.metricEnd || query.asOfDate, DEFAULT_END_DATE);
|
||
const metricStart = addDays(endDate, -29);
|
||
const feeCap = scopeStart >= '2026-07-22' ? 0.88 : 0.9;
|
||
return {
|
||
scopeStart,
|
||
scopeEnd: scopeEnd < scopeStart ? scopeStart : scopeEnd,
|
||
metricStart,
|
||
metricEnd: endDate,
|
||
feeCap,
|
||
ider: text(query.ider),
|
||
shopName: text(query.shopName),
|
||
spu: text(query.spu),
|
||
goodsId: text(query.goodsId),
|
||
};
|
||
}
|
||
|
||
function normalizeNumber(value) {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function normalizeRow(row = {}, query) {
|
||
const shippedDays30d = Number(row.shippedDays30d || 0);
|
||
const performance30d = normalizeNumber(row.performance30d);
|
||
const fee30d = normalizeNumber(row.fee30d);
|
||
const feeRatio30d = performance30d > 0 && fee30d !== null ? fee30d / performance30d : null;
|
||
const firstCreatedAt = row.firstCreatedAt ? new Date(row.firstCreatedAt) : null;
|
||
const listingStart = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||
? dayjs(firstCreatedAt).startOf('day')
|
||
: null;
|
||
const metricEndDay = dayjs(query.metricEnd).startOf('day');
|
||
const listedCalendarDays = listingStart && metricEndDay.isValid()
|
||
? Math.max(1, metricEndDay.diff(listingStart, 'day') + 1)
|
||
: 30;
|
||
const metricDenominatorDays = Math.min(30, listedCalendarDays);
|
||
const sellThroughRate30d = shippedDays30d / metricDenominatorDays;
|
||
const feeQualified = performance30d > 0 && feeRatio30d !== null && feeRatio30d <= query.feeCap;
|
||
const bonusQualified = feeQualified && sellThroughRate30d > 0.3;
|
||
const listingDays = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||
? Math.max(0, dayjs(query.metricEnd).diff(dayjs(firstCreatedAt), 'day'))
|
||
: null;
|
||
return {
|
||
...row,
|
||
goodsId: text(row.goodsId),
|
||
ider: text(row.ider),
|
||
shopName: text(row.shopName),
|
||
spu: text(row.spu),
|
||
goodsName: text(row.goodsName),
|
||
linkType: text(row.linkType),
|
||
status: Number(row.onsale || 0) === 1 ? '在售' : '已下架',
|
||
skuCount: Number(row.skuCount || 0),
|
||
listingDays,
|
||
julyPerformance: normalizeNumber(row.julyPerformance),
|
||
julyFee: normalizeNumber(row.julyFee),
|
||
julyFeeRatio: normalizeNumber(row.julyPerformance) > 0 ? normalizeNumber(row.julyFee) / normalizeNumber(row.julyPerformance) : null,
|
||
julyGoodsCount: normalizeNumber(row.julyGoodsCount) || 0,
|
||
julyShippedDays: Number(row.julyShippedDays || 0),
|
||
julySellThroughRate: Number(row.julyShippedDays || 0) / 31,
|
||
performance30d,
|
||
fee30d,
|
||
feeRatio30d,
|
||
goodsCount30d: normalizeNumber(row.goodsCount30d) || 0,
|
||
shippedDays30d,
|
||
metricDenominatorDays,
|
||
sellThroughRate30d,
|
||
feeQualified,
|
||
sellThroughQualified: sellThroughRate30d > 0.3,
|
||
bonusQualified,
|
||
bonus: bonusQualified ? AWARD_PER_ID : 0,
|
||
};
|
||
}
|
||
|
||
function buildParams(query) {
|
||
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||
return {
|
||
scopeStart: query.scopeStart,
|
||
scopeEnd: query.scopeEnd,
|
||
metricStart: query.metricStart,
|
||
metricEnd: query.metricEnd,
|
||
julyStart: JULY_START,
|
||
julyEnd: JULY_END,
|
||
ider: query.ider,
|
||
shopName: query.shopName,
|
||
spu: query.spu,
|
||
goodsId: query.goodsId,
|
||
scopeStartEpochSec: epoch(query.scopeStart),
|
||
scopeEndEpochSec: epoch(addDays(query.scopeEnd, 1)),
|
||
scopeStartEpochMs: epoch(query.scopeStart) * 1000,
|
||
scopeEndEpochMs: epoch(addDays(query.scopeEnd, 1)) * 1000,
|
||
};
|
||
}
|
||
|
||
function emptyMetrics() {
|
||
return { julyPerformance: 0, julyFee: 0, julyGoodsCount: 0, julyShippedDays: 0, performance30d: 0, fee30d: 0, goodsCount30d: 0, shippedDays30d: 0 };
|
||
}
|
||
|
||
async function loadSalesMetrics(goodsIds, query) {
|
||
const metrics = new Map();
|
||
const ids = [...new Set((goodsIds || []).map((value) => String(value || '').trim()).filter((value) => /^\d+$/u.test(value)))];
|
||
for (let offset = 0; offset < ids.length; offset += 1000) {
|
||
const chunk = ids.slice(offset, offset + 1000);
|
||
const inList = chunk.map((id) => `'${id}'`).join(',');
|
||
const rows = await cloudDatabase.query(`
|
||
SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) AS goodsId,
|
||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS julyPerformance,
|
||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS julyFee,
|
||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS julyGoodsCount,
|
||
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS julyShippedDays,
|
||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS performance30d,
|
||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS fee30d,
|
||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS goodsCount30d,
|
||
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS shippedDays30d
|
||
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||
WHERE d.item_id IN (${inList})
|
||
AND d.stat_date BETWEEN @julyStart AND @metricEnd
|
||
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id)))
|
||
`, { julyStart: JULY_START, julyEnd: JULY_END, metricStart: query.metricStart, metricEnd: query.metricEnd }, { requestTimeout: 180000 });
|
||
rows.forEach((row) => metrics.set(String(row.goodsId), row));
|
||
}
|
||
return metrics;
|
||
}
|
||
|
||
// pdd_Skus 由外部采集系统写入,存在残缺批次(如 2026-09-02 仅 113 个商品)。
|
||
// 从 upperBound 往回 7 天内挑最新一个商品数不低于窗口最大值 70% 的批次,避免用到不完整采集。
|
||
async function pickSkuCaptureTime(upperBound) {
|
||
const end = dayjs(upperBound).isAfter(dayjs(), 'day') ? dayjs().format('YYYY-MM-DD') : dayjs(upperBound).format('YYYY-MM-DD');
|
||
const dates = Array.from({ length: 7 }, (_, index) => dayjs(end).subtract(index, 'day').format('YYYY-MM-DD'));
|
||
const counts = await Promise.all(dates.map((captureTime) => jianSunDb.query(
|
||
'SELECT COUNT(DISTINCT goodsId) AS goodsCount FROM dbo.pdd_Skus WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE createTime = @captureTime',
|
||
{ captureTime }, { requestTimeout: 60000 }
|
||
)));
|
||
const mapped = dates.map((dateValue, index) => ({ date: dateValue, goodsCount: Number(counts[index]?.[0]?.goodsCount || 0) }));
|
||
const maxCount = Math.max(...mapped.map((item) => item.goodsCount));
|
||
if (maxCount <= 0) return null;
|
||
return (mapped.find((item) => item.goodsCount >= maxCount * 0.7) || mapped.find((item) => item.goodsCount === maxCount)).date;
|
||
}
|
||
|
||
async function getOpsBIncentiveBoardLive(input = {}) {
|
||
const query = resolveQuery(input);
|
||
const livePeriod = findPeriod(await loadPeriods(), query);
|
||
if (livePeriod) query.feeCap = livePeriod.feeCap;
|
||
const targetRows = await jianSunDb.query(`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid))) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.ider)))) AS ider, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), tm.shopname)))) AS shopName, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.spu)))) AS spu, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.linkType)))) AS linkType FROM dbo.op_TargetManagement tm WITH (NOLOCK) WHERE tm.dept = N'运营中心B' GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid)))`, {}, { requestTimeout: 60000 });
|
||
const latestCreateTime = await pickSkuCaptureTime(query.metricEnd);
|
||
if (!latestCreateTime) throw new Error('pdd_Skus 近7天无可用完整采集批次');
|
||
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||
const startSec = epoch(query.scopeStart);
|
||
const endSec = epoch(addDays(query.scopeEnd, 1));
|
||
const skuRows = await jianSunDb.query(`SELECT CONVERT(NVARCHAR(100), s.goodsId) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.goodsName)))) AS goodsName, MIN(DATEADD(SECOND, CONVERT(INT, CASE WHEN s.createdAt >= 1000000000000 THEN s.createdAt / 1000 ELSE s.createdAt END), CONVERT(DATETIME2, '1970-01-01'))) AS firstCreatedAt, MAX(CASE WHEN TRY_CONVERT(INT, s.isOnsale) = 1 THEN 1 ELSE 0 END) AS onsale, COUNT_BIG(1) AS skuCount FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE s.createTime = @latestCreateTime AND ((s.createdAt >= @startSec AND s.createdAt < @endSec) OR (s.createdAt >= @startMs AND s.createdAt < @endMs)) GROUP BY s.goodsId`, { latestCreateTime, startSec, endSec, startMs: startSec * 1000, endMs: endSec * 1000 }, { requestTimeout: 180000 });
|
||
const targetMap = new Map(targetRows.map((row) => [String(row.goodsId), row]));
|
||
const scopeRows = skuRows.flatMap((row) => {
|
||
const target = targetMap.get(String(row.goodsId));
|
||
if (!target) return [];
|
||
if ((query.ider && target.ider !== query.ider) || (query.shopName && target.shopName !== query.shopName) || (query.spu && !String(target.spu || '').includes(query.spu)) || (query.goodsId && !String(row.goodsId).includes(query.goodsId))) return [];
|
||
return [{ ...row, ...target }];
|
||
});
|
||
const salesMetrics = await loadSalesMetrics(scopeRows.map((row) => row.goodsId), query);
|
||
const rows = scopeRows.map((row) => ({ ...emptyMetrics(), ...row, ...(salesMetrics.get(String(row.goodsId)) || {}) }));
|
||
let dwsLatestRows = [];
|
||
try {
|
||
dwsLatestRows = await cloudDatabase.query(`SELECT MAX(TRY_CONVERT(DATE, stat_date)) AS latestDate FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] WITH (NOLOCK) WHERE ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), platform))), N'') IN (N'Pinduoduo', N'拼多多')`, {}, { requestTimeout: 60000 });
|
||
} catch (error) {
|
||
console.warn('[OpsBIncentiveBoard] latest DWS date unavailable:', error.message);
|
||
}
|
||
const normalizedRows = (rows || []).map((row) => normalizeRow(row, query));
|
||
const qualifiedRows = normalizedRows.filter((row) => row.bonusQualified);
|
||
const ownerMap = new Map();
|
||
normalizedRows.forEach((row) => {
|
||
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||
current.goodsIdCount += 1;
|
||
current.goodsCount30d += row.goodsCount30d;
|
||
current.shippedDays30d += row.shippedDays30d;
|
||
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||
current.bonus += row.bonus;
|
||
ownerMap.set(row.ider, current);
|
||
});
|
||
return {
|
||
rows: normalizedRows,
|
||
bonusRows: qualifiedRows,
|
||
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||
summary: {
|
||
goodsIdCount: normalizedRows.length,
|
||
feeQualifiedCount: normalizedRows.filter((row) => row.feeQualified).length,
|
||
sellThroughQualifiedCount: normalizedRows.filter((row) => row.sellThroughQualified).length,
|
||
bonusQualifiedCount: qualifiedRows.length,
|
||
bonus: qualifiedRows.length * AWARD_PER_ID,
|
||
awardPerId: AWARD_PER_ID,
|
||
},
|
||
query: { ...query, julyStart: JULY_START, julyEnd: JULY_END, latestDwsDate: dwsLatestRows?.[0]?.latestDate || null },
|
||
};
|
||
}
|
||
|
||
// 口径来源:运营中心B激励方案批次表。pullDate = 拉取(冻结)日期,统计窗口固定为 pullDate 往前 30 天。
|
||
// 批次配置存 MySQL ops_b_incentive_periods 表(可动态新增批次,无需发版),此列表仅作首次种子与兜底。
|
||
const DEFAULT_SNAPSHOT_PERIODS = [
|
||
{ scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 },
|
||
{ scopeStart: '2026-07-22', scopeEnd: '2026-07-28', pullDate: '2026-09-05', feeCap: 0.88 },
|
||
{ scopeStart: '2026-07-29', scopeEnd: '2026-08-04', pullDate: '2026-09-12', feeCap: 0.875 },
|
||
{ scopeStart: '2026-08-05', scopeEnd: '2026-08-11', pullDate: '2026-09-19', feeCap: 0.87 },
|
||
{ scopeStart: '2026-08-12', scopeEnd: '2026-08-18', pullDate: '2026-09-26', feeCap: 0.865 },
|
||
{ scopeStart: '2026-08-19', scopeEnd: '2026-08-25', pullDate: '2026-10-03', feeCap: 0.86 },
|
||
{ scopeStart: '2026-08-26', scopeEnd: '2026-09-01', pullDate: '2026-10-10', feeCap: 0.855 },
|
||
{ scopeStart: '2026-09-02', scopeEnd: '2026-09-08', pullDate: '2026-10-17', feeCap: 0.85 },
|
||
{ scopeStart: '2026-09-09', scopeEnd: '2026-09-15', pullDate: '2026-10-24', feeCap: 0.845 },
|
||
{ scopeStart: '2026-09-16', scopeEnd: '2026-09-22', pullDate: '2026-10-31', feeCap: 0.84 },
|
||
];
|
||
|
||
function makePeriodKey(scopeStart, scopeEnd) {
|
||
return `ops-b-${scopeStart.replace(/-/g, '')}-${scopeEnd.replace(/-/g, '')}`;
|
||
}
|
||
|
||
let periodsCache = null;
|
||
let periodsCacheExpiresAt = 0;
|
||
|
||
async function loadPeriods() {
|
||
if (periodsCache && Date.now() < periodsCacheExpiresAt) return periodsCache;
|
||
let rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||
if (!rows.length) {
|
||
for (const period of DEFAULT_SNAPSHOT_PERIODS) {
|
||
await database.local.query(
|
||
'INSERT IGNORE INTO ops_b_incentive_periods (scope_start, scope_end, pull_date, fee_cap, status) VALUES (?, ?, ?, ?, ?)',
|
||
[period.scopeStart, period.scopeEnd, period.pullDate, period.feeCap, 'active']
|
||
);
|
||
}
|
||
rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||
}
|
||
periodsCache = rows
|
||
.filter((row) => String(row.status || 'active') === 'active')
|
||
.map((row) => ({
|
||
key: makePeriodKey(dayjs(row.scope_start).format('YYYY-MM-DD'), dayjs(row.scope_end).format('YYYY-MM-DD')),
|
||
scopeStart: dayjs(row.scope_start).format('YYYY-MM-DD'),
|
||
scopeEnd: dayjs(row.scope_end).format('YYYY-MM-DD'),
|
||
pullDate: dayjs(row.pull_date).format('YYYY-MM-DD'),
|
||
feeCap: Number(row.fee_cap),
|
||
}));
|
||
periodsCacheExpiresAt = Date.now() + 60000;
|
||
return periodsCache;
|
||
}
|
||
|
||
function findPeriod(periods, query) {
|
||
return periods.find((period) => period.scopeStart === query.scopeStart && period.scopeEnd === query.scopeEnd) || null;
|
||
}
|
||
|
||
async function readSnapshot(period) {
|
||
const rows = await database.local.query(
|
||
'SELECT rows_json, summary_json, metric_end, finalized, generated_at FROM ops_b_incentive_snapshots WHERE snapshot_key = ? AND status = ? LIMIT 1',
|
||
[period.key, 'completed']
|
||
);
|
||
const row = rows[0];
|
||
if (!row) return null;
|
||
try {
|
||
const meta = JSON.parse(row.summary_json || '{}');
|
||
return { rows: JSON.parse(row.rows_json || '[]'), summary: meta.summary || {}, ownerSummary: meta.ownerSummary || [], metricEnd: row.metric_end, finalized: Boolean(row.finalized), generatedAt: row.generated_at };
|
||
} catch (error) {
|
||
console.warn('[OpsBIncentiveSnapshot] invalid snapshot:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function writeSnapshot(period, query, result, finalized) {
|
||
await database.local.query(`
|
||
INSERT INTO ops_b_incentive_snapshots
|
||
(snapshot_key, scope_start, scope_end, metric_end, status, finalized, rows_json, summary_json, generated_at, completed_at, error_message)
|
||
VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, NOW(), NOW(), NULL)
|
||
ON DUPLICATE KEY UPDATE
|
||
metric_end = VALUES(metric_end), finalized = VALUES(finalized), rows_json = VALUES(rows_json), summary_json = VALUES(summary_json),
|
||
generated_at = VALUES(generated_at), completed_at = VALUES(completed_at), status = 'completed', error_message = NULL
|
||
`, [period.key, query.scopeStart, query.scopeEnd, query.metricEnd, finalized ? 1 : 0, JSON.stringify(result.rows || []), JSON.stringify({ summary: result.summary || {}, ownerSummary: result.ownerSummary || [] })]);
|
||
}
|
||
|
||
async function getOpsBIncentiveBoard(input = {}) {
|
||
const query = resolveQuery(input);
|
||
const period = findPeriod(await loadPeriods(), query);
|
||
if (period) query.feeCap = period.feeCap;
|
||
if (!period) return getOpsBIncentiveBoardLive(input);
|
||
const existing = await readSnapshot(period);
|
||
if (!existing) {
|
||
return {
|
||
pending: true,
|
||
rows: [],
|
||
bonusRows: [],
|
||
ownerSummary: [],
|
||
summary: {},
|
||
query: { ...query, latestDwsDate: null, snapshot: { cached: false, finalized: false, pending: true } },
|
||
};
|
||
}
|
||
const filteredRows = existing.rows.filter((row) => (
|
||
(!query.ider || row.ider === query.ider)
|
||
&& (!query.shopName || row.shopName === query.shopName)
|
||
&& (!query.spu || String(row.spu || '').includes(query.spu))
|
||
&& (!query.goodsId || String(row.goodsId || '').includes(query.goodsId))
|
||
));
|
||
const ownerMap = new Map();
|
||
filteredRows.forEach((row) => {
|
||
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||
current.goodsIdCount += 1;
|
||
current.goodsCount30d += Number(row.goodsCount30d || 0);
|
||
current.shippedDays30d += Number(row.shippedDays30d || 0);
|
||
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||
current.bonus += Number(row.bonus || 0);
|
||
ownerMap.set(row.ider, current);
|
||
});
|
||
const bonusRows = filteredRows.filter((row) => row.bonusQualified);
|
||
return {
|
||
...existing,
|
||
rows: filteredRows,
|
||
bonusRows,
|
||
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||
summary: {
|
||
goodsIdCount: filteredRows.length,
|
||
feeQualifiedCount: filteredRows.filter((row) => row.feeQualified).length,
|
||
sellThroughQualifiedCount: filteredRows.filter((row) => row.sellThroughQualified).length,
|
||
bonusQualifiedCount: bonusRows.length,
|
||
bonus: bonusRows.reduce((sum, row) => sum + Number(row.bonus || 0), 0),
|
||
awardPerId: AWARD_PER_ID,
|
||
},
|
||
query: {
|
||
...query,
|
||
metricEnd: dayjs(existing.metricEnd).format('YYYY-MM-DD'),
|
||
latestDwsDate: null,
|
||
snapshot: { cached: true, finalized: existing.finalized, generatedAt: existing.generatedAt },
|
||
},
|
||
};
|
||
}
|
||
|
||
async function refreshOpsBIncentiveSnapshots(source = 'scheduled') {
|
||
const today = dayjs().format('YYYY-MM-DD');
|
||
const results = [];
|
||
for (const period of await loadPeriods()) {
|
||
const { scopeStart, scopeEnd } = period;
|
||
// 拉取日次日首刷才定档,确保统计窗口包含拉取日全天数据(数仓滞后约一天)。
|
||
const finalized = today > period.pullDate;
|
||
const existing = await readSnapshot(period);
|
||
if (existing?.finalized) {
|
||
results.push({ key: period.key, rows: existing.rows.length, finalized: true, skipped: true, source });
|
||
continue;
|
||
}
|
||
const result = await getOpsBIncentiveBoardLive({ scopeStart, scopeEnd, metricEnd: period.pullDate });
|
||
await writeSnapshot(period, { scopeStart, scopeEnd, metricEnd: period.pullDate }, result, finalized);
|
||
results.push({ key: period.key, rows: result.rows.length, finalized, source });
|
||
}
|
||
return results;
|
||
}
|
||
|
||
async function getOpsBIncentiveBatches() {
|
||
const periods = await loadPeriods();
|
||
const snapshots = await database.local.query(
|
||
"SELECT snapshot_key, finalized, generated_at, JSON_EXTRACT(summary_json, '$.summary.goodsIdCount') goodsIdCount, JSON_EXTRACT(summary_json, '$.summary.bonusQualifiedCount') bonusQualifiedCount, JSON_EXTRACT(summary_json, '$.summary.bonus') bonus FROM ops_b_incentive_snapshots WHERE status = 'completed'"
|
||
);
|
||
const snapshotMap = new Map(snapshots.map((row) => [row.snapshot_key, row]));
|
||
return periods.map((period, index) => {
|
||
const snapshot = snapshotMap.get(period.key) || {};
|
||
return {
|
||
...period,
|
||
batchNo: index + 1,
|
||
finalized: Boolean(snapshot.finalized),
|
||
generatedAt: snapshot.generated_at || null,
|
||
goodsIdCount: Number(snapshot.goodsIdCount || 0),
|
||
bonusQualifiedCount: Number(snapshot.bonusQualifiedCount || 0),
|
||
bonus: Number(snapshot.bonus || 0),
|
||
};
|
||
});
|
||
}
|
||
|
||
async function getOpsBIncentiveDaily(input = {}) {
|
||
const query = resolveQuery(input);
|
||
const params = buildParams(query);
|
||
if (!query.goodsId) return { rows: [], query };
|
||
const rows = await cloudDatabase.query(`
|
||
WITH daily AS (
|
||
SELECT TRY_CONVERT(DATE, d.stat_date) AS statDate,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.goods_count), 0)) AS goodsCount,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_performance), 0)) AS realPerformance,
|
||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_conclude_fee), 0)) AS realFee
|
||
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) = @goodsId
|
||
AND TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd
|
||
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||
GROUP BY TRY_CONVERT(DATE, d.stat_date)
|
||
) SELECT statDate, goodsCount, CASE WHEN goodsCount > 0 THEN N'是' ELSE N'否' END AS isActive, realPerformance, realFee
|
||
FROM daily ORDER BY statDate ASC
|
||
`, params, { requestTimeout: 120000 });
|
||
return { rows: rows || [], query };
|
||
}
|
||
|
||
module.exports = { getOpsBIncentiveBoard, getOpsBIncentiveBoardLive, getOpsBIncentiveDaily, getOpsBIncentiveBatches, refreshOpsBIncentiveSnapshots, resolveQuery, pickSkuCaptureTime, AWARD_PER_ID, DEFAULT_SNAPSHOT_PERIODS };
|