cangku/backend/src/api/productAnalysis.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

1740 lines
66 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 express = require('express');
const database = require('../config/database');
const jianSunDb = require('../config/jianSunDatabase');
const middleDb = require('../config/middlePlatformDatabase');
const planningDb = require('../services/planningDatabase');
const { resolveQualityRefundSignal } = require('../services/productQualitySignal');
const router = express.Router();
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
const PRODUCT_SPREAD_TABLE = 'pdd_ReportGoodsSpread';
const USER_PREFS_TABLE = 'product_analysis_user_prefs';
const PRODUCT_SIGNAL_PREFS_KEY = 'productAnalysisSignalThresholds';
const PRODUCT_META_CACHE_TTL_MS = 5 * 60 * 1000;
const PRODUCT_SOURCE_CACHE_TTL_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_SOURCE_CACHE_TTL_MS) || 60 * 1000, 10 * 1000);
const PRODUCT_SIGNAL_CACHE_TTL_MS = Math.max(
Number(process.env.PRODUCT_ANALYSIS_SIGNAL_CACHE_TTL_MS) || 5 * 60 * 1000,
60 * 1000
);
const PRODUCT_SIGNAL_WINDOW_DAYS = 30;
const PRODUCT_SIGNAL_OBSERVATION_DAYS = 7;
const PRODUCT_SIGNAL_STATUSES = new Set(['red', 'yellow', 'green']);
const DEFAULT_PRODUCT_SIGNAL_THRESHOLDS = Object.freeze({
ctr: Object.freeze({ low: 3, high: 5 }),
conversion: Object.freeze({ low: 0.3, high: 0.5 }),
});
const TAB_KEYS = new Set([
'refunds',
'promotion',
'activities',
'navigator',
'service-issues',
'bad-reviews',
'deductions',
]);
const PRODUCT_SORT_FIELDS = Object.freeze({
payOrdrAmt: 'payOrdrAmt',
orderNum: 'orderNum',
payOrdrUsrCnt: 'payOrdrUsrCnt',
payOrdrGoodsQty: 'payOrdrGoodsQty',
goodsUv: 'goodsUv',
goodsVcr: 'goodsVcr',
customerUnitPrice: 'customerUnitPrice',
});
const DASHBOARD_PERIOD_DAYS = Object.freeze({ day: 1, week: 7, month: 30 });
const DASHBOARD_CACHE_TTL_MS = 60 * 1000;
const DASHBOARD_QUERY_TIMEOUT_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000);
const dashboardCache = new Map();
const dashboardInFlight = new Map();
const productSignalCache = new Map();
const productSignalItemCache = new Map();
const productSignalSnapshotCache = new Map();
const productSignalSnapshotInFlight = new Map();
const productSourceCache = new Map();
const productSourceInFlight = new Map();
let productMetaCache = null;
let productMetaInFlight = null;
let productDateCache = null;
let productDateInFlight = null;
let productMappingCache = null;
let productMappingInFlight = null;
function positiveInt(value, fallback, max = 200) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
return Math.min(parsed, max);
}
function toNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizeDate(value) {
const text = String(value || '').trim();
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
}
function toDateString(value) {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10);
}
function toLocalDateString(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
function addDays(value, offset) {
const date = new Date(`${value}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offset);
return date.toISOString().slice(0, 10);
}
function inclusiveDayCount(startDate, endDate) {
const start = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
}
function percentChange(current, previous) {
if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0) return null;
return ((current - previous) * 100) / Math.abs(previous);
}
function sumRows(rows) {
return rows.reduce((result, row) => {
result.payAmount += toNumber(row.payAmount);
result.buyerCount += toNumber(row.buyerCount);
result.visitorCount += toNumber(row.visitorCount);
result.goodsPv += toNumber(row.goodsPv);
result.payGoodsQty += toNumber(row.payGoodsQty);
result.payOrderCount += toNumber(row.payOrderCount);
result.favoriteCount += toNumber(row.favoriteCount);
result.impressionCount += toNumber(row.impressionCount);
return result;
}, {
payAmount: 0,
buyerCount: 0,
visitorCount: 0,
goodsPv: 0,
payGoodsQty: 0,
payOrderCount: 0,
favoriteCount: 0,
impressionCount: 0,
});
}
async function runDashboardQuery(name, sqlText, params = {}) {
try {
return {
name,
rows: await jianSunDb.query(sqlText, params, { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }),
error: null,
};
} catch (error) {
console.warn(`[product-analysis/dashboard] ${name} query failed:`, error.message);
return { name, rows: [], error };
}
}
function buildDashboardMetrics(current, previous) {
const conversionRate = current.visitorCount > 0
? (current.buyerCount * 100) / current.visitorCount
: null;
const previousConversionRate = previous.visitorCount > 0
? (previous.buyerCount * 100) / previous.visitorCount
: null;
return [
{ key: 'payAmount', label: '支付金额', value: current.payAmount, previous: previous.payAmount, change: percentChange(current.payAmount, previous.payAmount), format: 'money' },
{ key: 'payOrderCount', label: '支付订单数', value: Math.round(current.payOrderCount), previous: Math.round(previous.payOrderCount), change: percentChange(current.payOrderCount, previous.payOrderCount), format: 'integer' },
{ key: 'visitorCount', label: '访客数', value: Math.round(current.visitorCount), previous: Math.round(previous.visitorCount), change: percentChange(current.visitorCount, previous.visitorCount), format: 'integer' },
{ key: 'buyerCount', label: '支付买家数', value: Math.round(current.buyerCount), previous: Math.round(previous.buyerCount), change: percentChange(current.buyerCount, previous.buyerCount), format: 'integer' },
{ key: 'conversionRate', label: '支付转化率', value: conversionRate, previous: previousConversionRate, change: percentChange(conversionRate, previousConversionRate), format: 'percent' },
{ key: 'payGoodsQty', label: '支付件数', value: Math.round(current.payGoodsQty), previous: Math.round(previous.payGoodsQty), change: percentChange(current.payGoodsQty, previous.payGoodsQty), format: 'integer' },
{ key: 'favoriteCount', label: '收藏人数', value: Math.round(current.favoriteCount), previous: Math.round(previous.favoriteCount), change: percentChange(current.favoriteCount, previous.favoriteCount), format: 'integer' },
{ key: 'goodsPv', label: '商品浏览量', value: Math.round(current.goodsPv), previous: Math.round(previous.goodsPv), change: percentChange(current.goodsPv, previous.goodsPv), format: 'integer' },
];
}
function buildEmptyDashboardMetrics() {
return [
{ key: 'payAmount', label: '支付金额', value: null, previous: null, change: null, format: 'money' },
{ key: 'payOrderCount', label: '支付订单数', value: null, previous: null, change: null, format: 'integer' },
{ key: 'visitorCount', label: '访客数', value: null, previous: null, change: null, format: 'integer' },
{ key: 'buyerCount', label: '支付买家数', value: null, previous: null, change: null, format: 'integer' },
{ key: 'conversionRate', label: '支付转化率', value: null, previous: null, change: null, format: 'percent' },
{ key: 'payGoodsQty', label: '支付件数', value: null, previous: null, change: null, format: 'integer' },
{ key: 'favoriteCount', label: '收藏人数', value: null, previous: null, change: null, format: 'integer' },
{ key: 'goodsPv', label: '商品浏览量', value: null, previous: null, change: null, format: 'integer' },
];
}
function normalizeDepartments(...values) {
const result = [];
values.filter(Boolean).forEach((value) => {
if (Array.isArray(value)) {
result.push(...value);
return;
}
const text = String(value).trim();
if (!text) return;
try {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
result.push(...parsed);
return;
}
} catch {
// Fall back to comma-separated legacy values.
}
result.push(...text.split(/[,,]/));
});
return [...new Set(result.map((item) => String(item || '').trim()).filter(Boolean))];
}
async function getDataScope(userId) {
const rows = await database.local.query(
`SELECT u.id, u.role, u.department_names, d.name AS department_name
FROM users u
LEFT JOIN departments d ON d.id = u.department_id
WHERE u.id = ?
LIMIT 1`,
[userId]
);
const user = rows[0];
if (!user) return null;
return {
isAllData: user.role === 'admin',
departments: normalizeDepartments(user.department_names, user.department_name),
};
}
function appendScope(whereParts, params, scope) {
if (scope.isAllData) return;
if (!scope.departments.length) {
whereParts.push('1 = 0');
return;
}
whereParts.push(`ownerDept IN (${scope.departments.map(() => '?').join(', ')})`);
params.push(...scope.departments);
}
async function resolveSnapshotDate(requestedDate) {
const normalized = normalizeDate(requestedDate);
if (normalized) return normalized;
const rows = await planningDb.query(
`SELECT DATE_FORMAT(MAX(statDate), '%Y-%m-%d') AS latestDate
FROM ${PRODUCT_SNAPSHOT_TABLE}`
);
return rows[0]?.latestDate || '';
}
async function resolveDateRange(startValue, endValue) {
const requestedStartDate = normalizeDate(startValue);
const requestedEndDate = normalizeDate(endValue);
const dateRange = requestedStartDate && requestedEndDate ? null : await loadProductDateRange();
const latestDate = dateRange?.latestDate || requestedEndDate || requestedStartDate || '';
const minDate = dateRange?.minDate || requestedStartDate || latestDate;
const startDate = requestedStartDate || latestDate;
const endDate = requestedEndDate || latestDate;
return {
minDate,
latestDate,
startDate: startDate <= endDate ? startDate : endDate,
endDate: startDate <= endDate ? endDate : startDate,
};
}
function getRequestUserId(req) {
const userId = Number(req.user?.sub || req.user?.id || req.user?.userId || 0);
return Number.isSafeInteger(userId) && userId > 0 ? userId : null;
}
function normalizeProductSignalThresholds(value) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const normalizeMetric = (metric, defaults) => {
const low = Number(metric?.low);
const high = Number(metric?.high);
return {
low: Number.isFinite(low) ? Math.min(100, Math.max(0, low)) : defaults.low,
high: Number.isFinite(high) ? Math.min(100, Math.max(0, high)) : defaults.high,
};
};
return {
ctr: normalizeMetric(source.ctr, DEFAULT_PRODUCT_SIGNAL_THRESHOLDS.ctr),
conversion: normalizeMetric(source.conversion, DEFAULT_PRODUCT_SIGNAL_THRESHOLDS.conversion),
};
}
function parseUserPrefsStorage(value) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function readProductSignalThresholds(value) {
const storage = parseUserPrefsStorage(value);
const saved = storage[PRODUCT_SIGNAL_PREFS_KEY];
const configured = Boolean(saved && typeof saved === 'object' && !Array.isArray(saved));
return {
configured,
thresholds: normalizeProductSignalThresholds(configured ? saved : null),
};
}
function validateProductSignalThresholds(thresholds) {
if (thresholds.ctr.low >= thresholds.ctr.high) {
return '点击率的黄绿分界值必须大于红黄分界值';
}
if (thresholds.conversion.low >= thresholds.conversion.high) {
return '转化率的黄绿分界值必须大于红黄分界值';
}
return '';
}
async function persistProductSignalThresholds(userId, thresholds) {
const patch = JSON.stringify({ [PRODUCT_SIGNAL_PREFS_KEY]: thresholds });
try {
await database.local.query(
`INSERT INTO ${USER_PREFS_TABLE} (user_id, prefs_json)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH(
IF(JSON_VALID(prefs_json), prefs_json, '{}'),
VALUES(prefs_json)
)`,
[userId, patch]
);
} catch (error) {
const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET']
.includes(error.code);
if (!canFallback) throw error;
const rows = await database.local.query(
`SELECT prefs_json FROM ${USER_PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
[userId]
);
const storage = parseUserPrefsStorage(rows[0]?.prefs_json);
storage[PRODUCT_SIGNAL_PREFS_KEY] = thresholds;
await database.local.query(
`INSERT INTO ${USER_PREFS_TABLE} (user_id, prefs_json)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`,
[userId, JSON.stringify(storage)]
);
}
}
async function loadUserProductSignalThresholds(userId) {
const rows = await database.local.query(
`SELECT prefs_json
FROM ${USER_PREFS_TABLE}
WHERE user_id = ?
LIMIT 1`,
[userId]
);
return readProductSignalThresholds(rows[0]?.prefs_json);
}
function resolveProductSignalStatus(signal, thresholds) {
const statuses = [];
if (signal?.signalWindowEndDate && !signal.signalIsObserving) {
const ctr = toNumber(signal.signalCtr);
const conversion = toNumber(signal.signalClickConversionRate);
statuses.push(ctr < thresholds.ctr.low
? 'red'
: ctr < thresholds.ctr.high ? 'yellow' : 'green');
statuses.push(conversion < thresholds.conversion.low
? 'red'
: conversion < thresholds.conversion.high ? 'yellow' : 'green');
}
if (signal?.signalQualityStatus) statuses.push(signal.signalQualityStatus);
if (statuses.includes('red')) return 'red';
if (statuses.includes('yellow')) return 'yellow';
return statuses.includes('green') ? 'green' : '';
}
async function loadProductDateRange() {
if (productDateCache?.expiresAt > Date.now()) return productDateCache.data;
if (productDateInFlight) return productDateInFlight;
productDateInFlight = middleDb.query(
`SELECT MIN(createTime) AS minDate, MAX(createTime) AS latestDate
FROM dbo.pdd_DataGoods`
).then((rows) => {
const latestDate = normalizeDate(rows[0]?.latestDate);
const data = {
latestDate,
minDate: normalizeDate(rows[0]?.minDate) || latestDate,
};
productDateCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS };
return data;
}).finally(() => {
productDateInFlight = null;
});
return productDateInFlight;
}
async function loadProductMeta() {
if (productMetaCache?.expiresAt > Date.now()) return productMetaCache.data;
if (productMetaInFlight) return productMetaInFlight;
productMetaInFlight = Promise.all([
loadProductDateRange(),
middleDb.query(
`SELECT DISTINCT shopName
FROM dbo.pdd_DataGoods
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
ORDER BY shopName`
),
loadProductMapping(),
]).then(([dateRange, shopRows, mappingRows]) => {
const owners = [...new Set([...mappingRows.values()]
.map((row) => String(row.idOwner || '').trim())
.filter(Boolean))]
.sort((left, right) => left.localeCompare(right, 'zh-Hans-CN'));
const data = {
platform: 'pdd',
...dateRange,
shops: shopRows.map((row) => row.shopName),
owners,
};
productMetaCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS };
return data;
}).finally(() => {
productMetaInFlight = null;
});
return productMetaInFlight;
}
async function loadProductMapping() {
if (productMappingCache?.expiresAt > Date.now()) return productMappingCache.data;
if (productMappingInFlight) return productMappingInFlight;
productMappingInFlight = planningDb.query(
`SELECT m.goodsId, m.spu, m.skuCode, m.idOwner, m.ownerDept, m.spend, m.isPriceBreak,
f.first_created_at AS firstCreatedAt
FROM ${PRODUCT_SNAPSHOT_TABLE} m
LEFT JOIN pdd_goods_first_seen f ON f.goods_id = m.goodsId
WHERE m.statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})`
).then((rows) => {
const data = new Map(rows.map((row) => [String(row.goodsId), {
...row,
firstCreatedAt: toLocalDateString(row.firstCreatedAt),
}]));
productMappingCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS };
return data;
}).finally(() => {
productMappingInFlight = null;
});
return productMappingInFlight;
}
function pruneProductSourceCache() {
if (productSourceCache.size <= 30) return;
const now = Date.now();
for (const [key, entry] of productSourceCache) {
if (entry.expiresAt <= now || productSourceCache.size > 20) productSourceCache.delete(key);
}
}
async function loadProductSourceRows(startDate, endDate, shopName) {
const cacheKey = JSON.stringify({ startDate, endDate, shopName });
const cached = productSourceCache.get(cacheKey);
if (cached?.expiresAt > Date.now()) return cached.data;
if (productSourceInFlight.has(cacheKey)) return productSourceInFlight.get(cacheKey);
const whereParts = [
'createTime >= @startDate',
'createTime <= @endDate',
'goodsId IS NOT NULL',
];
const params = { startDate, endDate };
if (shopName) {
whereParts.push('shopName = @shopName');
params.shopName = shopName;
}
const pending = middleDb.query(
`SELECT
CONVERT(varchar(50), goodsId) AS goodsId,
shopName,
MAX(goodsName) AS goodsName,
MAX(hdThumbUrl) AS hdThumbUrl,
MAX(cate1Name) AS cate1Name,
MAX(cate2Name) AS cate2Name,
MAX(cate3Name) AS cate3Name,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsUv), 0)) AS goodsUv,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsPv), 0)) AS goodsPv,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrAmt), 0)) AS payOrdrAmt,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrGoodsQty), 0)) AS payOrdrGoodsQty,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrCnt), 0)) AS orderNum,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrUsrCnt), 0)) AS payOrdrUsrCnt,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsFavCnt), 0)) AS goodsFavCnt,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), imprUsrCnt), 0)) AS imprUsrCnt
FROM dbo.pdd_DataGoods
WHERE ${whereParts.join(' AND ')}
GROUP BY shopName, goodsId`,
params
).then((rows) => {
productSourceCache.set(cacheKey, {
data: rows,
expiresAt: Date.now() + PRODUCT_SOURCE_CACHE_TTL_MS,
});
pruneProductSourceCache();
return rows;
}).finally(() => {
productSourceInFlight.delete(cacheKey);
});
productSourceInFlight.set(cacheKey, pending);
return pending;
}
function buildProductSignalRecord(firstObservedDate, spread, signalStartDate, signalEndDate) {
const normalizedFirstObservedDate = normalizeDate(firstObservedDate);
const observedDays = normalizedFirstObservedDate
? Math.max(1, inclusiveDayCount(normalizedFirstObservedDate, signalEndDate))
: null;
const clicks = toNumber(spread?.clicks);
const impressions = toNumber(spread?.impressions);
const orders = toNumber(spread?.orders);
return {
signalWindowStartDate: signalStartDate,
signalWindowEndDate: signalEndDate,
signalFirstObservedDate: normalizedFirstObservedDate || null,
signalObservedDays: observedDays,
signalIsObserving: observedDays !== null && observedDays <= PRODUCT_SIGNAL_OBSERVATION_DAYS,
signalCtr: impressions > 0 ? (clicks * 100) / impressions : 0,
signalClickConversionRate: clicks > 0 ? (orders * 100) / clicks : 0,
signalImpressions: impressions,
signalClicks: clicks,
signalOrders: orders,
};
}
function buildProductQualitySignalRecord(row) {
if (!row) return {};
const ranking = resolveQualityRefundSignal(row.secondaryStage, row.tertiaryStage);
const refundRate = Number(row.qualityRefundRate);
return {
signalQualityStatDate: normalizeDate(row.qualityStatDate) || null,
signalQualityRefundRate: Number.isFinite(refundRate) ? refundRate : null,
signalQualitySecondaryStage: Number(row.secondaryStage) || null,
signalQualityTertiaryStage: Number(row.tertiaryStage) || null,
signalQualitySecondaryLabel: ranking.secondaryLabel,
signalQualityTertiaryLabel: ranking.tertiaryLabel,
signalQualityMainCategoryLabel: ranking.mainCategoryLabel,
signalQualityStatus: ranking.status,
signalQualityReason: ranking.reason,
};
}
function mergeProductQualitySignals(data, qualityRows) {
qualityRows.forEach((row) => {
const goodsId = String(row.goodsId);
data.set(goodsId, {
...(data.get(goodsId) || {}),
...buildProductQualitySignalRecord(row),
});
});
return data;
}
async function loadProductSignalSnapshot(endDate) {
const normalizedEndDate = normalizeDate(endDate);
if (!normalizedEndDate) return new Map();
const signalStartDate = addDays(normalizedEndDate, -(PRODUCT_SIGNAL_WINDOW_DAYS - 1));
const cacheKey = `${signalStartDate}:${normalizedEndDate}`;
const cached = productSignalSnapshotCache.get(cacheKey);
if (cached?.expiresAt > Date.now()) return cached.data;
if (productSignalSnapshotInFlight.has(cacheKey)) return productSignalSnapshotInFlight.get(cacheKey);
const params = { signalStartDate, signalEndDate: normalizedEndDate };
const qualityEndExclusive = addDays(normalizedEndDate, 1);
const pending = Promise.all([
middleDb.query(
`SELECT CONVERT(varchar(50), goodsId) AS goodsId, MIN(createTime) AS firstObservedDate
FROM dbo.pdd_DataGoods
WHERE createTime >= @signalStartDate
AND createTime <= @signalEndDate
AND goodsId IS NOT NULL
GROUP BY goodsId`,
params
),
middleDb.query(
`SELECT CONVERT(varchar(50), goodsId) AS goodsId,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), [click]), 0)) AS clicks,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), impression), 0)) AS impressions,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), orderNum), 0)) AS orders
FROM dbo.${PRODUCT_SPREAD_TABLE}
WHERE createTime >= @signalStartDate
AND createTime <= @signalEndDate
AND goodsId IS NOT NULL
GROUP BY goodsId`,
params
),
jianSunDb.query(
`WITH qualityGoods AS (
SELECT DISTINCT goodsId
FROM dbo.pdd_GoodsQualities
WHERE goodsId IS NOT NULL
)
SELECT CONVERT(varchar(50), qualityGoods.goodsId) AS goodsId,
latestQuality.createTime AS qualityStatDate,
latestQuality.qltyRfndOrdrCntRto1m AS qualityRefundRate,
latestQuality.rankQltyRfndOrdrCntRto1mStage AS secondaryStage,
latestQuality.rankQltyRfndOrdrCntRto1mStplStage AS tertiaryStage
FROM qualityGoods
CROSS APPLY (
SELECT TOP (1)
createTime,
qltyRfndOrdrCntRto1m,
rankQltyRfndOrdrCntRto1mStage,
rankQltyRfndOrdrCntRto1mStplStage
FROM dbo.pdd_GoodsQualities
WHERE goodsId = qualityGoods.goodsId
AND createTime < @qualityEndExclusive
ORDER BY createTime DESC, id DESC
) AS latestQuality`,
{ qualityEndExclusive }
),
]).then(([listingRows, spreadRows, qualityRows]) => {
const listingMap = new Map(listingRows.map((row) => [String(row.goodsId), row.firstObservedDate]));
const spreadMap = new Map(spreadRows.map((row) => [String(row.goodsId), row]));
const goodsIds = new Set([...listingMap.keys(), ...spreadMap.keys()]);
const data = new Map([...goodsIds].map((goodsId) => [
goodsId,
buildProductSignalRecord(
listingMap.get(goodsId),
spreadMap.get(goodsId),
signalStartDate,
normalizedEndDate
),
]));
mergeProductQualitySignals(data, qualityRows);
productSignalSnapshotCache.set(cacheKey, {
data,
expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS,
});
if (productSignalSnapshotCache.size > 5) {
const now = Date.now();
for (const [key, entry] of productSignalSnapshotCache) {
if (entry.expiresAt <= now || productSignalSnapshotCache.size > 3) {
productSignalSnapshotCache.delete(key);
}
}
}
return data;
}).finally(() => {
productSignalSnapshotInFlight.delete(cacheKey);
});
productSignalSnapshotInFlight.set(cacheKey, pending);
return pending;
}
async function loadProductSignals(goodsIds, endDate) {
const normalizedGoodsIds = [...new Set(goodsIds.map((value) => String(value || '').trim()).filter(Boolean))];
const normalizedEndDate = normalizeDate(endDate);
if (!normalizedGoodsIds.length || !normalizedEndDate) return new Map();
const signalStartDate = addDays(normalizedEndDate, -(PRODUCT_SIGNAL_WINDOW_DAYS - 1));
const result = new Map();
const missingGoodsIds = [];
const now = Date.now();
normalizedGoodsIds.forEach((goodsId) => {
const itemCacheKey = `${signalStartDate}:${normalizedEndDate}:${goodsId}`;
const cachedItem = productSignalItemCache.get(itemCacheKey);
if (cachedItem?.expiresAt > now) {
result.set(goodsId, cachedItem.data);
} else {
missingGoodsIds.push(goodsId);
}
});
if (!missingGoodsIds.length) return result;
const cacheKey = `${signalStartDate}:${normalizedEndDate}:${[...missingGoodsIds].sort().join(',')}`;
const cached = productSignalCache.get(cacheKey);
if (cached?.expiresAt > now) {
cached.data.forEach((value, key) => {
productSignalItemCache.set(`${signalStartDate}:${normalizedEndDate}:${key}`, {
data: value,
expiresAt: cached.expiresAt,
});
result.set(key, value);
});
return result;
}
const params = { signalStartDate, signalEndDate: normalizedEndDate };
const qualityParams = { qualityEndExclusive: addDays(normalizedEndDate, 1) };
const placeholders = missingGoodsIds.map((goodsId, index) => {
const key = `signalGoodsId${index}`;
params[key] = goodsId;
qualityParams[key] = goodsId;
return `@${key}`;
}).join(', ');
const [listingRows, spreadRows, qualityRows] = await Promise.all([
middleDb.query(
`SELECT CONVERT(varchar(50), goodsId) AS goodsId, MIN(createTime) AS firstObservedDate
FROM dbo.pdd_DataGoods
WHERE createTime >= @signalStartDate
AND createTime <= @signalEndDate
AND goodsId IN (${placeholders})
GROUP BY goodsId`,
params
),
middleDb.query(
`SELECT CONVERT(varchar(50), goodsId) AS goodsId,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), [click]), 0)) AS clicks,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), impression), 0)) AS impressions,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), orderNum), 0)) AS orders
FROM dbo.${PRODUCT_SPREAD_TABLE}
WHERE createTime >= @signalStartDate
AND createTime <= @signalEndDate
AND goodsId IN (${placeholders})
GROUP BY goodsId`,
params
),
jianSunDb.query(
`WITH latestQuality AS (
SELECT CONVERT(varchar(50), goodsId) AS goodsId,
createTime AS qualityStatDate,
qltyRfndOrdrCntRto1m AS qualityRefundRate,
rankQltyRfndOrdrCntRto1mStage AS secondaryStage,
rankQltyRfndOrdrCntRto1mStplStage AS tertiaryStage,
ROW_NUMBER() OVER (
PARTITION BY goodsId
ORDER BY createTime DESC, id DESC
) AS rowNumber
FROM dbo.pdd_GoodsQualities
WHERE goodsId IN (${placeholders})
AND createTime < @qualityEndExclusive
)
SELECT goodsId, qualityStatDate, qualityRefundRate, secondaryStage, tertiaryStage
FROM latestQuality
WHERE rowNumber = 1`,
qualityParams
),
]);
const listingMap = new Map(listingRows.map((row) => [String(row.goodsId), normalizeDate(row.firstObservedDate)]));
const spreadMap = new Map(spreadRows.map((row) => [String(row.goodsId), row]));
const data = new Map(missingGoodsIds.map((goodsId) => [
goodsId,
buildProductSignalRecord(
listingMap.get(goodsId),
spreadMap.get(goodsId),
signalStartDate,
normalizedEndDate
),
]));
mergeProductQualitySignals(data, qualityRows);
productSignalCache.set(cacheKey, { data, expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS });
if (productSignalCache.size > 100) {
const now = Date.now();
for (const [key, entry] of productSignalCache) {
if (entry.expiresAt <= now || productSignalCache.size > 80) productSignalCache.delete(key);
}
}
data.forEach((value, key) => {
productSignalItemCache.set(`${signalStartDate}:${normalizedEndDate}:${key}`, {
data: value,
expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS,
});
result.set(key, value);
});
if (productSignalItemCache.size > 500) {
const itemNow = Date.now();
for (const [key, entry] of productSignalItemCache) {
if (entry.expiresAt <= itemNow || productSignalItemCache.size > 400) productSignalItemCache.delete(key);
}
}
return result;
}
function asNumericRow(row) {
const numericKeys = [
'goodsUv', 'goodsPv', 'payOrdrAmt', 'payOrdrGoodsQty', 'orderNum',
'payOrdrUsrCnt', 'goodsVcr', 'goodsFavCnt', 'imprUsrCnt',
];
const result = { ...row };
numericKeys.forEach((key) => {
result[key] = Number(row[key] || 0);
});
return result;
}
function buildTabPayload(tabKey, product, history) {
const historyRows = [...history].reverse();
const common = {
tabKey,
product,
source: PRODUCT_SNAPSHOT_TABLE,
};
if (tabKey === 'activities') {
return {
...common,
status: 'empty',
message: '店铺后台宝贝活动数据源待接入',
metrics: [],
trend: [],
columns: [],
rows: [],
};
}
if (tabKey === 'deductions') {
return {
...common,
status: 'empty',
message: '店铺后台账单订单扣款数据源待接入',
metrics: [],
trend: [],
columns: [],
rows: [],
};
}
if (tabKey === 'refunds') {
return {
...common,
status: 'ready',
metrics: [
{ key: 'afterSaleCount', label: '售后单量', value: product.afterSaleCount },
{ key: 'afterSaleRate', label: '售后率', value: product.afterSaleRate, unit: '%' },
{ key: 'payOrdrAmt', label: '销售额', value: product.payOrdrAmt, unit: '¥' },
{ key: 'orderNum', label: '订单量', value: product.orderNum },
],
trend: historyRows.map((row) => ({
date: row.statDate,
primary: row.afterSaleCount,
secondary: row.afterSaleRate,
})),
trendLabels: { primary: '售后单量', secondary: '售后率' },
columns: [
{ key: 'statDate', title: '数据日期' },
{ key: 'afterSaleCount', title: '售后单量' },
{ key: 'afterSaleRate', title: '售后率' },
{ key: 'payOrdrAmt', title: '销售额' },
{ key: 'orderNum', title: '订单量' },
],
rows: history,
};
}
if (tabKey === 'promotion') {
return {
...common,
status: 'ready',
metrics: [
{ key: 'spend', label: '推广花费', value: product.spend, unit: '¥' },
{ key: 'feeRatio', label: '真实费比', value: product.feeRatio, unit: '%' },
{ key: 'payOrdrAmt', label: '销售额', value: product.payOrdrAmt, unit: '¥' },
{ key: 'goodsVcr', label: '成交转化率', value: product.goodsVcr, unit: '%' },
],
trend: historyRows.map((row) => ({
date: row.statDate,
primary: row.spend,
secondary: row.payOrdrAmt,
})),
trendLabels: { primary: '推广花费', secondary: '销售额' },
columns: [
{ key: 'statDate', title: '数据日期' },
{ key: 'spend', title: '推广花费' },
{ key: 'feeRatio', title: '真实费比' },
{ key: 'payOrdrAmt', title: '销售额' },
{ key: 'goodsVcr', title: '成交转化率' },
],
rows: history,
};
}
if (tabKey === 'navigator') {
return {
...common,
status: 'ready',
metrics: [
{ key: 'goodsUv', label: '访客数', value: product.goodsUv },
{ key: 'ctr', label: '点击率', value: product.ctr, unit: '%' },
{ key: 'clickConversionRate', label: '点击转化率', value: product.clickConversionRate, unit: '%' },
{ key: 'goodsVcr', label: '成交转化率', value: product.goodsVcr, unit: '%' },
],
trend: historyRows.map((row) => ({
date: row.statDate,
primary: row.goodsUv,
secondary: row.goodsVcr,
})),
trendLabels: { primary: '访客数', secondary: '成交转化率' },
columns: [
{ key: 'statDate', title: '数据日期' },
{ key: 'goodsUv', title: '访客数' },
{ key: 'ctr', title: '点击率' },
{ key: 'clickConversionRate', title: '点击转化率' },
{ key: 'goodsVcr', title: '成交转化率' },
{ key: 'imprUsrCnt', title: '流失指数' },
{ key: 'unpaidUserCount', title: '未支付人数' },
],
rows: history,
};
}
return {
...common,
status: 'ready',
metrics: [
{ key: 'badReviewCount', label: '差评数量', value: product.badReviewCount },
{ key: 'afterSaleCount', label: '售后单量', value: product.afterSaleCount },
{ key: 'afterSaleRate', label: '售后率', value: product.afterSaleRate, unit: '%' },
{ key: 'payOrdrUsrCnt', label: '成交买家数', value: product.payOrdrUsrCnt },
],
trend: historyRows.map((row) => ({
date: row.statDate,
primary: row.badReviewCount,
secondary: row.afterSaleCount,
})),
trendLabels: { primary: '差评数量', secondary: '售后单量' },
columns: [
{ key: 'statDate', title: '数据日期' },
{ key: 'badReviewCount', title: '差评数量' },
{ key: 'afterSaleCount', title: '售后单量' },
{ key: 'afterSaleRate', title: '售后率' },
{ key: 'payOrdrUsrCnt', title: '成交买家数' },
],
rows: history,
};
}
async function buildActivityPayload(product, startDate, endDate) {
const startTimestamp = new Date(`${startDate}T00:00:00+08:00`).getTime();
const endTimestamp = new Date(`${endDate}T23:59:59.999+08:00`).getTime();
const rows = await jianSunDb.query(
`SELECT TOP (200)
id,
business_type_str AS activityType,
activity_name AS activityName,
activity_id AS activityId,
CONVERT(varchar(19), DATEADD(
SECOND,
(TRY_CONVERT(bigint, [enroll_time ]) / 1000) % 86400,
DATEADD(DAY, (TRY_CONVERT(bigint, [enroll_time ]) / 1000) / 86400, CONVERT(datetime2, '1970-01-01'))
), 120) AS enrollTime,
TRY_CONVERT(decimal(18, 2), min_activity_sku_price) / 100.0 AS minPrice,
TRY_CONVERT(decimal(18, 2), max_activity_sku_price) / 100.0 AS maxPrice,
CONVERT(varchar(19), DATEADD(
SECOND,
(TRY_CONVERT(bigint, activity_goods_start_time) / 1000) % 86400,
DATEADD(DAY, (TRY_CONVERT(bigint, activity_goods_start_time) / 1000) / 86400, CONVERT(datetime2, '1970-01-01'))
), 120) AS activityStartTime,
CONVERT(varchar(19), DATEADD(
SECOND,
(TRY_CONVERT(bigint, activity_goods_end_time) / 1000) % 86400,
DATEADD(DAY, (TRY_CONVERT(bigint, activity_goods_end_time) / 1000) / 86400, CONVERT(datetime2, '1970-01-01'))
), 120) AS activityEndTime,
status,
CASE WHEN status = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', status) END AS statusLabel
FROM dbo.pdd_DataActivityRecords
WHERE TRY_CONVERT(bigint, goods_id) = @goodsId
AND (
(TRY_CONVERT(bigint, activity_goods_end_time) >= @startTimestamp
AND TRY_CONVERT(bigint, activity_goods_start_time) <= @endTimestamp)
OR TRY_CONVERT(date, createTime) BETWEEN @startDate AND @endDate
)
ORDER BY TRY_CONVERT(bigint, activity_goods_start_time) DESC, id DESC`,
{
goodsId: product.goodsId,
startTimestamp,
endTimestamp,
startDate,
endDate,
}
);
if (!rows.length) {
return {
tabKey: 'activities',
product,
source: 'JianSun.dbo.pdd_DataActivityRecords',
status: 'empty',
message: '当前商品在所选区间暂无活动报名数据',
metrics: [],
trend: [],
columns: [],
rows: [],
};
}
const successCount = rows.filter((row) => Number(row.status) === 501).length;
const activityTypes = new Set(rows.map((row) => row.activityType).filter(Boolean)).size;
return {
tabKey: 'activities',
product,
source: 'JianSun.dbo.pdd_DataActivityRecords',
status: 'ready',
metrics: [
{ key: 'activityCount', label: '活动记录', value: rows.length },
{ key: 'successCount', label: '报名成功', value: successCount },
{ key: 'activityTypes', label: '活动类型', value: activityTypes },
{ key: 'successRate', label: '报名成功率', value: (successCount * 100) / rows.length, unit: '%' },
],
trend: [],
columns: [
{ key: 'activityName', title: '活动名称' },
{ key: 'activityType', title: '活动类型' },
{ key: 'enrollTime', title: '提交时间' },
{ key: 'activityStartTime', title: '活动开始时间' },
{ key: 'activityEndTime', title: '活动结束时间' },
{ key: 'minPrice', title: '活动最低价' },
{ key: 'maxPrice', title: '活动最高价' },
{ key: 'statusLabel', title: '状态' },
],
rows,
};
}
async function buildQualityPayload(product, endDate) {
const rows = await jianSunDb.query(
`SELECT TOP (30)
id,
CONVERT(varchar(10), TRY_CONVERT(date, createTime), 23) AS statDate,
avgDescRevScr1m,
qltyRfndOrdrCntRto1m,
ptHelpPzRate30d,
midRto30d,
displayMallScoreRk,
rankAvgDescRevScr1mStage,
rankQltyRfndOrdrCntRto1mStage,
rankQltyRfndOrdrCntRto1mStplStage,
rankPtHelpPzRate30dStage,
[rankComscoreStage ] AS rankComscoreStage,
goodsScoreRkStage
FROM dbo.pdd_GoodsQualities
WHERE goodsId = @goodsId
AND createTime < @qualityEndExclusive
ORDER BY createTime DESC, id DESC`,
{ goodsId: product.goodsId, qualityEndExclusive: addDays(endDate, 1) }
);
if (!rows.length) {
return {
tabKey: 'navigator',
product,
source: 'JianSun.dbo.pdd_GoodsQualities',
status: 'empty',
message: '当前商品暂无品质领航员数据',
metrics: [],
trend: [],
columns: [],
rows: [],
};
}
const latest = rows[0];
return {
tabKey: 'navigator',
product,
source: 'JianSun.dbo.pdd_GoodsQualities',
status: 'ready',
metrics: [
{ key: 'avgDescRevScr1m', label: '品质评价得分', value: latest.avgDescRevScr1m },
{ key: 'qltyRfndOrdrCntRto1m', label: '品质退款率', value: latest.qltyRfndOrdrCntRto1m, unit: '%' },
{ key: 'ptHelpPzRate30d', label: '品质平台求助率', value: latest.ptHelpPzRate30d, unit: '%' },
{ key: 'midRto30d', label: '品质文本情感分', value: latest.midRto30d },
],
trend: [...rows].reverse().map((row) => ({
date: row.statDate,
primary: row.avgDescRevScr1m,
secondary: row.qltyRfndOrdrCntRto1m,
})),
trendLabels: { primary: '品质评价得分', secondary: '品质退款率' },
columns: [
{ key: 'statDate', title: '数据日期' },
{ key: 'avgDescRevScr1m', title: '品质评价得分' },
{ key: 'qltyRfndOrdrCntRto1m', title: '品质退款率' },
{ key: 'ptHelpPzRate30d', title: '品质平台求助率' },
{ key: 'midRto30d', title: '品质文本情感分' },
{ key: 'goodsScoreRkStage', title: '综合得分表现' },
],
rows,
};
}
async function buildServiceIssuesPayload(product, statDate) {
const rows = await jianSunDb.query(
`SELECT
id,
COALESCE(NULLIF(LTRIM(RTRIM(title)), N''), N'其他问题') AS issueType,
NULLIF(LTRIM(RTRIM(orderSn)), '') AS orderSn,
CASE WHEN isNeg = 1 THEN 1 ELSE 0 END AS isNegative,
CONVERT(varchar(19), TRY_CONVERT(datetime2, seekHelpTime), 120) AS seekHelpTime,
NULLIF(LTRIM(RTRIM(probDetail)), N'') AS problemDetail,
NULLIF(LTRIM(RTRIM(clfy2Desc)), N'') AS categoryDescription
FROM dbo.pdd_ServiceProblemOrders WITH (NOLOCK)
WHERE goodsId = TRY_CONVERT(bigint, @goodsId)
AND createTime = @statDate
ORDER BY TRY_CONVERT(datetime2, seekHelpTime) DESC, id DESC`,
{
goodsId: product.goodsId,
statDate,
}
);
const detailMap = new Map();
rows.forEach((row) => {
const orderSn = String(row.orderSn || '').trim();
if (!orderSn) return;
const issueType = String(row.issueType || '其他问题').trim() || '其他问题';
const key = `${issueType}\u0000${orderSn}`;
const current = detailMap.get(key);
if (current) {
current.isNegative = current.isNegative || Number(row.isNegative) === 1;
if (!current.problemDetail && row.problemDetail) current.problemDetail = row.problemDetail;
if (!current.categoryDescription && row.categoryDescription) {
current.categoryDescription = row.categoryDescription;
}
return;
}
detailMap.set(key, {
id: Number(row.id) || key,
issueType,
orderSn,
isNegative: Number(row.isNegative) === 1,
seekHelpTime: row.seekHelpTime || '',
problemDetail: row.problemDetail || '',
categoryDescription: row.categoryDescription || '',
});
});
const orderDetails = [...detailMap.values()];
if (!orderDetails.length) {
return {
tabKey: 'service-issues',
product,
source: 'JianSun.dbo.pdd_ServiceProblemOrders',
status: 'empty',
message: `当前商品在 ${statDate} 暂无体验问题数据`,
metrics: [],
trend: [],
columns: [],
rows: [],
orderDetails: [],
};
}
const issueGroups = new Map();
const allOrders = new Map();
orderDetails.forEach((row) => {
if (!issueGroups.has(row.issueType)) issueGroups.set(row.issueType, []);
issueGroups.get(row.issueType).push(row);
const existing = allOrders.get(row.orderSn);
allOrders.set(row.orderSn, Boolean(existing || row.isNegative));
});
const latestSummary = [...issueGroups.entries()]
.map(([issueType, issueRows]) => ({
id: `${statDate}:${issueType}`,
statDate,
issueType,
issueOrderCount: issueRows.length,
negativeOrderCount: issueRows.filter((row) => row.isNegative).length,
latestSeekHelpTime: issueRows[0]?.seekHelpTime || '',
viewOrders: '查看订单',
}))
.sort((left, right) => (
right.issueOrderCount - left.issueOrderCount
|| left.issueType.localeCompare(right.issueType, 'zh-CN')
));
const productIssueCount = latestSummary
.filter((row) => row.issueType === '商品问题')
.reduce((sum, row) => sum + row.issueOrderCount, 0);
const customerServiceIssueCount = latestSummary
.filter((row) => row.issueType.includes('客服'))
.reduce((sum, row) => sum + row.issueOrderCount, 0);
return {
tabKey: 'service-issues',
product,
source: 'JianSun.dbo.pdd_ServiceProblemOrders',
status: 'ready',
latestDate: statDate,
metrics: [
{ key: 'issueOrderCount', label: '问题订单', value: allOrders.size },
{ key: 'productIssueCount', label: '商品问题', value: productIssueCount },
{ key: 'customerServiceIssueCount', label: '客服问题', value: customerServiceIssueCount },
{ key: 'negativeOrderCount', label: '负向体验', value: [...allOrders.values()].filter(Boolean).length },
],
trend: [],
tableTitle: `问题汇总 · ${statDate}`,
columns: [
{ key: 'issueType', title: '问题类型', align: 'left' },
{ key: 'issueOrderCount', title: '问题订单数', align: 'right' },
{ key: 'negativeOrderCount', title: '负向体验订单', align: 'right' },
{ key: 'latestSeekHelpTime', title: '最近求助时间', align: 'left' },
{ key: 'viewOrders', title: '操作', align: 'left' },
],
rows: latestSummary,
orderDetails,
};
}
router.get('/signal-thresholds', async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' });
}
res.json({
success: true,
data: await loadUserProductSignalThresholds(userId),
});
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.put('/signal-thresholds', async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' });
}
const thresholds = normalizeProductSignalThresholds(req.body?.thresholds ?? req.body);
const validationError = validateProductSignalThresholds(thresholds);
if (validationError) {
return res.status(400).json({ success: false, error: validationError });
}
await persistProductSignalThresholds(userId, thresholds);
res.json({ success: true, data: { configured: true, thresholds } });
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/meta', async (req, res) => {
try {
const scope = await getDataScope(req.user.sub);
if (!scope) return res.status(401).json({ success: false, error: 'user not found' });
res.json({ success: true, data: await loadProductMeta() });
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/dashboard', async (req, res) => {
let settleDashboard = null;
let activeCacheKey = '';
try {
const requestedStartDate = normalizeDate(req.query.startDate);
const requestedEndDate = normalizeDate(req.query.endDate);
const hasCustomDate = Boolean(requestedStartDate || requestedEndDate);
if (hasCustomDate && (!requestedStartDate || !requestedEndDate)) {
return res.status(400).json({ success: false, error: '自定义日期需要同时选择开始日期和结束日期' });
}
const customDays = hasCustomDate ? inclusiveDayCount(requestedStartDate, requestedEndDate) : 0;
if (hasCustomDate && customDays < 1) {
return res.status(400).json({ success: false, error: '开始日期不能晚于结束日期' });
}
if (hasCustomDate && customDays > 90) {
return res.status(400).json({ success: false, error: '自定义日期范围不能超过90天' });
}
const requestedPeriod = hasCustomDate
? 'custom'
: DASHBOARD_PERIOD_DAYS[req.query.period] ? req.query.period : 'week';
const shopName = String(req.query.shopName || '').trim();
const cacheKey = JSON.stringify({
period: requestedPeriod,
startDate: requestedStartDate,
endDate: requestedEndDate,
shopName,
});
activeCacheKey = cacheKey;
const cached = dashboardCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
const pending = dashboardInFlight.get(cacheKey);
if (pending) {
const result = await pending;
if (result.error) throw result.error;
return res.json({ success: true, data: result.data });
}
dashboardInFlight.set(cacheKey, new Promise((resolve) => {
settleDashboard = resolve;
}));
const period = requestedPeriod;
const days = hasCustomDate ? customDays : DASHBOARD_PERIOD_DAYS[period];
const [salesResult, serviceResult, starResult] = await Promise.all([
runDashboardQuery(
'sales',
`WITH source_bounds AS (
SELECT TOP (1) LEFT(createTime, 10) AS maxDate
FROM dbo.pdd_DataGoods
WHERE createTime >= '2000-01-01'
AND createTime < '2100-01-01'
AND (@shopName = '' OR shopName = @shopName)
ORDER BY createTime DESC
),
daily_coverage AS (
SELECT LEFT(g.createTime, 10) AS statDate, COUNT(DISTINCT g.shopName) AS shopCount
FROM dbo.pdd_DataGoods g
CROSS JOIN source_bounds b
WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, -45, TRY_CONVERT(date, b.maxDate)), 23)
AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, b.maxDate)), 23)
AND g.shopName IS NOT NULL
AND LTRIM(RTRIM(g.shopName)) <> ''
AND (@shopName = '' OR g.shopName = @shopName)
GROUP BY LEFT(g.createTime, 10)
),
coverage_target AS (
SELECT MAX(shopCount) AS maxShopCount FROM daily_coverage
),
available_end AS (
SELECT MAX(c.statDate) AS latestDate
FROM daily_coverage c
CROSS JOIN coverage_target t
WHERE c.shopCount >= t.maxShopCount * 0.8
),
selected_end AS (
SELECT CASE
WHEN @requestedEndDate <> '' AND @requestedEndDate < latestDate THEN @requestedEndDate
ELSE latestDate
END AS latestDate
FROM available_end
)
SELECT
LEFT(g.createTime, 10) AS statDate,
e.latestDate AS latestDate,
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrAmt), 0)) AS payAmount,
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrUsrCnt), 0)) AS buyerCount,
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsUv), 0)) AS visitorCount,
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsPv), 0)) AS goodsPv,
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrGoodsQty), 0)) AS payGoodsQty,
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrCnt), 0)) AS payOrderCount,
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsFavCnt), 0)) AS favoriteCount,
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.imprUsrCnt), 0)) AS impressionCount
FROM dbo.pdd_DataGoods g
CROSS JOIN selected_end e
WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, 1 - @historyDays, TRY_CONVERT(date, e.latestDate)), 23)
AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, e.latestDate)), 23)
AND (@shopName = '' OR g.shopName = @shopName)
GROUP BY LEFT(g.createTime, 10), e.latestDate
ORDER BY LEFT(g.createTime, 10)`,
{ historyDays: days * 2, requestedEndDate: requestedEndDate || '', shopName }
),
runDashboardQuery(
'service-score',
`WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber
FROM dbo.pdd_MallServiceScore
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
AND createTime LIKE '[12][0-9][0-9][0-9]-%'
AND (@shopName = '' OR shopName = @shopName)
)
SELECT
MAX(LEFT(createTime, 10)) AS createTime,
COUNT(*) AS shopCount,
AVG(TRY_CONVERT(decimal(18, 6), cstmrServScore)) AS cstmrServScore,
AVG(TRY_CONVERT(decimal(18, 6), spLmScore)) AS spLmScore,
AVG(TRY_CONVERT(decimal(18, 6), fhLmScore)) AS fhLmScore,
AVG(TRY_CONVERT(decimal(18, 6), wlLmScore)) AS wlLmScore
FROM ranked
WHERE rowNumber = 1`,
{ shopName }
),
runDashboardQuery(
'shop-stars',
`WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber
FROM dbo.pdd_ShopStars
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
AND createTime LIKE '[12][0-9][0-9][0-9]-%'
AND (@shopName = '' OR shopName = @shopName)
)
SELECT
MAX(LEFT(createTime, 10)) AS createTime,
COUNT(*) AS shopCount,
AVG(TRY_CONVERT(decimal(18, 6), mallStarTomms)) AS mallStarTomms,
AVG(TRY_CONVERT(decimal(18, 6), gmvRk)) AS gmvRk,
AVG(TRY_CONVERT(decimal(18, 6), scoreRk)) AS scoreRk
FROM ranked
WHERE rowNumber = 1`,
{ shopName }
),
]);
const salesRows = salesResult.rows;
const serviceRows = serviceResult.rows;
const starRows = starResult.rows;
const warnings = [salesResult, serviceResult, starResult]
.filter((result) => result.error)
.map((result) => result.name);
const normalizedSales = salesRows
.map((row) => ({
date: String(row.statDate || ''),
payAmount: toNumber(row.payAmount),
buyerCount: toNumber(row.buyerCount),
visitorCount: toNumber(row.visitorCount),
goodsPv: toNumber(row.goodsPv),
payGoodsQty: toNumber(row.payGoodsQty),
payOrderCount: toNumber(row.payOrderCount),
favoriteCount: toNumber(row.favoriteCount),
impressionCount: toNumber(row.impressionCount),
}))
.filter((row) => normalizeDate(row.date));
const latestDate = String(salesRows[0]?.latestDate || normalizedSales.at(-1)?.date || '');
const currentStart = hasCustomDate ? requestedStartDate : latestDate ? addDays(latestDate, 1 - days) : '';
const previousEnd = currentStart ? addDays(currentStart, -1) : '';
const previousStart = previousEnd ? addDays(previousEnd, 1 - days) : '';
const currentRows = normalizedSales.filter((row) => row.date >= currentStart && row.date <= latestDate);
const previousRows = normalizedSales.filter((row) => row.date >= previousStart && row.date <= previousEnd);
const current = sumRows(currentRows);
const previous = sumRows(previousRows);
const metrics = latestDate && !salesResult.error
? buildDashboardMetrics(current, previous)
: buildEmptyDashboardMetrics();
const spend = null;
const gmv = null;
const roi = null;
const responseData = {
platform: 'pdd',
source: 'jiansun_database',
scope: shopName ? 'single-shop' : 'all-shops',
shopName: shopName || '全部店铺',
warnings,
period,
days,
latestDate,
startDate: currentStart,
previousStart,
previousEnd,
metrics,
trend: currentRows,
promotion: {
spend,
gmv,
roi,
clickCount: null,
orderCount: null,
},
refunds: {
amount: null,
rate: null,
products: [],
},
store: {
serviceDate: toDateString(serviceRows[0]?.createTime),
starDate: toDateString(starRows[0]?.createTime),
shopCount: Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount)),
star: starRows[0]?.mallStarTomms == null ? null : toNumber(starRows[0].mallStarTomms),
peerDescription: shopName
? shopName
: `已汇总 ${Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount))} 家店铺`,
activityRank: starRows[0]?.gmvRk == null ? null : toNumber(starRows[0].gmvRk),
scoreRank: starRows[0]?.scoreRk == null ? null : toNumber(starRows[0].scoreRk),
scores: [
{ key: 'experience', label: '服务体验', value: serviceRows[0]?.cstmrServScore == null ? null : toNumber(serviceRows[0].cstmrServScore) },
{ key: 'product', label: '商品服务', value: serviceRows[0]?.spLmScore == null ? null : toNumber(serviceRows[0].spLmScore) },
{ key: 'logistics', label: '物流服务', value: serviceRows[0]?.wlLmScore == null ? null : toNumber(serviceRows[0].wlLmScore) },
{ key: 'shipping', label: '发货服务', value: serviceRows[0]?.fhLmScore == null ? null : toNumber(serviceRows[0].fhLmScore) },
],
},
};
dashboardCache.set(cacheKey, { data: responseData, expiresAt: Date.now() + DASHBOARD_CACHE_TTL_MS });
settleDashboard({ data: responseData });
dashboardInFlight.delete(cacheKey);
res.json({ success: true, data: responseData });
} catch (error) {
settleDashboard?.({ error });
if (activeCacheKey) dashboardInFlight.delete(activeCacheKey);
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/products', async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' });
}
const scope = await getDataScope(userId);
if (!scope) return res.status(401).json({ success: false, error: 'user not found' });
const page = positiveInt(req.query.page, 1, 1000000);
const pageSize = positiveInt(req.query.pageSize, 20, 100);
const requestedStartDate = req.query.startDate || req.query.statDate;
const requestedEndDate = req.query.endDate || req.query.statDate;
const range = await resolveDateRange(requestedStartDate, requestedEndDate);
const rangeDays = positiveInt(req.query.rangeDays, 1, 90);
if (!normalizeDate(requestedStartDate) && !normalizeDate(requestedEndDate) && rangeDays > 1) {
range.startDate = addDays(range.endDate, -(rangeDays - 1));
}
const shopName = String(req.query.shopName || '').trim();
const ider = String(req.query.ider || '').trim();
const keyword = String(req.query.keyword || '').trim();
const listingFrom = normalizeDate(req.query.listingFrom);
const listingTo = normalizeDate(req.query.listingTo);
const onlyActive = ['1', 'true', 'yes'].includes(String(req.query.onlyActive || '').toLowerCase());
const requestedSignalStatus = String(req.query.signalStatus || '').trim().toLowerCase();
const signalStatus = PRODUCT_SIGNAL_STATUSES.has(requestedSignalStatus) ? requestedSignalStatus : '';
const sortField = PRODUCT_SORT_FIELDS[req.query.sortField] || 'payOrdrAmt';
const sortOrder = String(req.query.sortOrder || '').toLowerCase() === 'ascend' ? 'ASC' : 'DESC';
const [sourceRows, mappingRows] = await Promise.all([
loadProductSourceRows(range.startDate, range.endDate, shopName),
loadProductMapping(),
]);
const mapping = mappingRows;
const normalizedKeyword = keyword.toLowerCase();
let rows = sourceRows.map((sourceRow) => {
const row = asNumericRow(sourceRow);
const extra = mapping.get(String(row.goodsId)) || {};
const goodsUv = toNumber(row.goodsUv);
const buyers = toNumber(row.payOrdrUsrCnt);
const quantity = toNumber(row.payOrdrGoodsQty);
const amount = toNumber(row.payOrdrAmt);
return {
...row,
...extra,
statDate: range.endDate,
startDate: range.startDate,
endDate: range.endDate,
goodsVcr: goodsUv > 0 ? (buyers * 100) / goodsUv : 0,
customerUnitPrice: buyers > 0 ? amount / buyers : 0,
pieceUnitPrice: quantity > 0 ? amount / quantity : 0,
};
});
if (!scope.isAllData) {
const allowed = new Set(scope.departments);
rows = rows.filter((row) => row.ownerDept && allowed.has(row.ownerDept));
}
if (ider) {
rows = rows.filter((row) => String(row.idOwner || '').trim() === ider);
}
if (listingFrom || listingTo) {
rows = rows.filter((row) => {
if (!row.firstCreatedAt) return false;
if (listingFrom && row.firstCreatedAt < listingFrom) return false;
if (listingTo && row.firstCreatedAt > listingTo) return false;
return true;
});
}
if (normalizedKeyword) {
rows = rows.filter((row) => [row.goodsName, row.goodsId, row.skuCode, row.spu]
.some((value) => String(value || '').toLowerCase().includes(normalizedKeyword)));
}
if (onlyActive) {
rows = rows.filter((row) => toNumber(row.payOrdrAmt) > 0 || toNumber(row.spend) > 0);
}
if (signalStatus && rows.length) {
const [signalSnapshot, userSignalPrefs] = await Promise.all([
loadProductSignalSnapshot(range.endDate),
loadUserProductSignalThresholds(userId),
]);
rows = rows
.map((row) => ({
...row,
...(signalSnapshot.get(String(row.goodsId)) || {}),
}))
.filter((row) => resolveProductSignalStatus(row, userSignalPrefs.thresholds) === signalStatus);
}
const direction = sortOrder === 'ASC' ? 1 : -1;
rows.sort((left, right) => {
const difference = toNumber(left[sortField]) - toNumber(right[sortField]);
return difference === 0
? String(left.goodsId).localeCompare(String(right.goodsId))
: difference * direction;
});
const summary = rows.reduce((result, row) => {
result.payOrdrAmt += toNumber(row.payOrdrAmt);
result.orderNum += toNumber(row.orderNum);
result.payOrdrUsrCnt += toNumber(row.payOrdrUsrCnt);
result.payOrdrGoodsQty += toNumber(row.payOrdrGoodsQty);
result.goodsUv += toNumber(row.goodsUv);
result.spend += toNumber(row.spend);
if (toNumber(row.payOrdrAmt) > 0) result.activeGoods += 1;
return result;
}, {
total: rows.length,
activeGoods: 0,
payOrdrAmt: 0,
orderNum: 0,
payOrdrUsrCnt: 0,
payOrdrGoodsQty: 0,
goodsUv: 0,
spend: 0,
startDate: range.startDate,
endDate: range.endDate,
});
summary.goodsVcr = summary.goodsUv > 0
? (summary.payOrdrUsrCnt * 100) / summary.goodsUv
: 0;
const pagedRows = rows.slice((page - 1) * pageSize, page * pageSize);
let responseRows = pagedRows;
if (!signalStatus) {
let productSignals = new Map();
try {
productSignals = await loadProductSignals(pagedRows.map((row) => row.goodsId), range.endDate);
} catch (error) {
console.warn('[product-analysis/products] traffic signal query failed:', error.message);
}
responseRows = pagedRows.map((row) => ({
...row,
...(productSignals.get(String(row.goodsId)) || {}),
}));
}
res.json({
success: true,
data: {
page,
pageSize,
total: Number(summary.total || 0),
statDate: range.endDate,
startDate: range.startDate,
endDate: range.endDate,
signalStatus: signalStatus || 'all',
metrics: summary,
rows: responseRows,
},
});
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/products/:goodsId/tabs/:tabKey', async (req, res) => {
try {
const { goodsId, tabKey } = req.params;
if (!TAB_KEYS.has(tabKey)) {
return res.status(404).json({ success: false, error: 'unknown product analysis tab' });
}
const scope = await getDataScope(req.user.sub);
if (!scope) return res.status(401).json({ success: false, error: 'user not found' });
const range = await resolveDateRange(
req.query.startDate || req.query.statDate,
req.query.endDate || req.query.statDate
);
const scopeWhere = [];
const scopeParams = [];
appendScope(scopeWhere, scopeParams, scope);
const scopeSql = scopeWhere.length ? ` AND ${scopeWhere.join(' AND ')}` : '';
const [sourceProductRows, productRows] = await Promise.all([
middleDb.query(
`SELECT TOP (1)
CONVERT(varchar(50), goodsId) AS goodsId,
goodsName,
hdThumbUrl,
shopName,
cate1Name,
cate2Name,
cate3Name
FROM dbo.pdd_DataGoods
WHERE TRY_CONVERT(bigint, goodsId) = @goodsId
AND TRY_CONVERT(date, createTime) <= @endDate
ORDER BY TRY_CONVERT(date, createTime) DESC, createdAt DESC`,
{ goodsId, endDate: range.endDate }
),
planningDb.query(
`SELECT
DATE_FORMAT(statDate, '%Y-%m-%d') AS statDate,
DATE_FORMAT(startDate, '%Y-%m-%d') AS startDate,
DATE_FORMAT(endDate, '%Y-%m-%d') AS endDate,
spu, skuCode, goodsId, goodsName, hdThumbUrl, idOwner, ownerDept, shopName,
goodsUv, ctr, clickConversionRate, goodsVcr, ordrVstrRto, payOrdrRto,
imprUsrCnt, unpaidUserCount, payOrdrAmt, orderNum, payOrdrUsrCnt,
payOrdrGoodsQty, customerUnitPrice, pieceUnitPrice, spend, feeRatio,
afterSaleRate, afterSaleCount, badReviewCount, isPriceBreak
FROM ${PRODUCT_SNAPSHOT_TABLE}
WHERE goodsId = ?
AND statDate <= ?${scopeSql}
ORDER BY statDate DESC
LIMIT 1`,
[goodsId, range.endDate, ...scopeParams]
),
]);
const product = {
...(sourceProductRows[0] || {}),
...(productRows[0] || {}),
goodsId,
startDate: range.startDate,
endDate: range.endDate,
};
if (!scope.isAllData && !productRows[0]) {
return res.status(403).json({ success: false, error: 'product access denied' });
}
if (!sourceProductRows[0] && !productRows[0]) {
return res.status(404).json({ success: false, error: 'product not found' });
}
if (tabKey === 'activities') {
return res.json({ success: true, data: await buildActivityPayload(product, range.startDate, range.endDate) });
}
if (tabKey === 'navigator') {
return res.json({ success: true, data: await buildQualityPayload(product, range.endDate) });
}
if (tabKey === 'service-issues') {
return res.json({
success: true,
data: await buildServiceIssuesPayload(product, range.endDate),
});
}
const history = await planningDb.query(
`SELECT
DATE_FORMAT(statDate, '%Y-%m-%d') AS statDate,
goodsUv, ctr, clickConversionRate, goodsVcr, imprUsrCnt, unpaidUserCount,
payOrdrAmt, orderNum, payOrdrUsrCnt, payOrdrGoodsQty, spend, feeRatio,
afterSaleRate, afterSaleCount, badReviewCount
FROM ${PRODUCT_SNAPSHOT_TABLE}
WHERE goodsId = ?
AND statDate >= ?
AND statDate <= ?${scopeSql}
ORDER BY statDate DESC
LIMIT 60`,
[goodsId, range.startDate, range.endDate, ...scopeParams]
);
res.json({ success: true, data: buildTabPayload(tabKey, product, history) });
} catch (error) {
res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
module.exports = router;