cangku/backend/src/services/opsBIncentiveBoardService.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

431 lines
24 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 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 };