本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
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 排除。
1740 lines
66 KiB
JavaScript
1740 lines
66 KiB
JavaScript
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;
|