删除商品监控、激励、营销和商品分析功能 #3
@ -2,7 +2,7 @@
|
||||
|
||||
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
|
||||
|
||||
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
|
||||
保留仪表盘、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
|
||||
|
||||
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
|
||||
|
||||
@ -27,7 +27,7 @@ npm --prefix frontend run check
|
||||
|
||||
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
|
||||
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
|
||||
## 参与修改
|
||||
|
||||
|
||||
@ -9,8 +9,7 @@
|
||||
"syntax": "node scripts/check-syntax.js",
|
||||
"test": "node --test test/*.test.js",
|
||||
"check": "npm run syntax && npm test",
|
||||
"alert:check": "node src/services/alertChecker.js",
|
||||
"product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js"
|
||||
"alert:check": "node src/services/alertChecker.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.975.0",
|
||||
|
||||
@ -1,404 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 从《公司SABC标签配置.xlsx》导入商品标签配置到系统库。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=/path/to/公司SABC标签配置.xlsx
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --print-sql # 只打印 SQL,不写库
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --dry-run # 只解析并打印摘要
|
||||
*
|
||||
* 解析口径(2026-09-11 评审确认):
|
||||
* - 区块① A3:F150 → 规模标签阈值(148 行,37 品类 × 4 档)
|
||||
* - 区块② H4:L28 → SABC 映射(25 行,5 规模标签 × 5 利润标签)
|
||||
* - 阈值统一四舍五入保留 2 位小数(决策 5,2026-09-11 修订);品类为 '-' 或空的行忽略
|
||||
*
|
||||
* 前置:三张表需已存在(DBA 执行 sql/20260911_create_product_tag_config_system_mysql.sql,
|
||||
* 或由 ensureProductTagConfigSchema() 自动建表)。
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const database = require('../src/config/database');
|
||||
|
||||
const SCALE_TAG_ORDER = ['爆款', '热卖款', '动销款', '低动销款'];
|
||||
const IGNORED_CATEGORIES = ['-', ''];
|
||||
const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级'];
|
||||
|
||||
// 配置表聚合类目 → DB 类目 的兜底映射(按用户 2026-09-11 推测确认)。
|
||||
// xlsx 里「其他沙发类」「办公家具」本身是聚合类目,DB 中没有同名完整类目;
|
||||
// 其子品类在 DB 中独立存在,且未被别的配置名占用(懒人沙发/文具边柜已单独配置,故排除)。
|
||||
// 用「家族规则」而非逐个枚举叶子名,可自动覆盖 DB 中实际存在的全部子类目,避免名字写错产生幽灵行。
|
||||
// family: 'sofa' → 所有短名含「沙发」的类目,排除 exclude 列表
|
||||
// family: 'office'→ 所有「商业/办公家具 -」前缀的类目,排除 exclude 列表
|
||||
const MANUAL_CATEGORY_MAP = {
|
||||
'其他沙发类': { family: 'sofa', exclude: ['懒人沙发'] },
|
||||
'办公家具': { family: 'office', exclude: ['文具边柜'] },
|
||||
};
|
||||
|
||||
// 区块① / 区块② 在 sheet 中的位置(1-based,与 Excel 界面一致)
|
||||
const BLOCK1 = { startRow: 3, endRow: 150, cols: { category: 0, pMin: 1, pMax: 2, sMin: 3, sMax: 4, scaleTag: 5 } };
|
||||
const BLOCK2 = { startRow: 4, endRow: 28, cols: { scaleTag: 7, profitLevel: 8, profitText: 9, sabc: 11 } };
|
||||
|
||||
// 去掉「住宅家具 - 」这类一级类目前缀,只留叶子品类名
|
||||
function stripCategoryPrefix(value) {
|
||||
return String(value).replace(/^.*?\s+-\s+/, '').trim();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { file: '', printSql: false, dryRun: false };
|
||||
argv.slice(2).forEach((item) => {
|
||||
if (item.startsWith('--file=')) args.file = item.slice('--file='.length);
|
||||
else if (item === '--print-sql') args.printSql = true;
|
||||
else if (item === '--dry-run') args.dryRun = true;
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
// 决策 5(2026-09-11 11:12 修订):所有阈值统一「四舍五入保留 2 位小数」。
|
||||
// - 0.5 → 0.5(合法业务阈值,不变;不会变成 1)
|
||||
// - 溢出值 7916.34311148132 → 7916.34;2063.13142776357 → 2063.13
|
||||
// - 整数 12000 → 12000
|
||||
function normalizeThreshold(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return Math.round(num * 100) / 100;
|
||||
}
|
||||
|
||||
function sqlText(value) {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function sqlNumber(value) {
|
||||
return value === null || value === undefined ? 'NULL' : String(value);
|
||||
}
|
||||
|
||||
function readWorkbook(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在:${filePath}`);
|
||||
}
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null });
|
||||
return { sheetName, rows };
|
||||
}
|
||||
|
||||
function parseScaleThresholds(rows) {
|
||||
const list = [];
|
||||
const skipped = [];
|
||||
for (let rowNo = BLOCK1.startRow; rowNo <= BLOCK1.endRow; rowNo += 1) {
|
||||
const row = rows[rowNo - 1] || [];
|
||||
const category = text(row[BLOCK1.cols.category]);
|
||||
if (IGNORED_CATEGORIES.includes(category)) {
|
||||
if (category) skipped.push({ rowNo, reason: `品类为 "${category}",按决策 6 忽略` });
|
||||
continue;
|
||||
}
|
||||
const scaleTag = text(row[BLOCK1.cols.scaleTag]);
|
||||
if (!SCALE_TAG_ORDER.includes(scaleTag)) {
|
||||
skipped.push({ rowNo, reason: `规模标签 "${scaleTag}" 不在枚举内` });
|
||||
continue;
|
||||
}
|
||||
const raw = {
|
||||
performanceMin: row[BLOCK1.cols.pMin],
|
||||
performanceMax: row[BLOCK1.cols.pMax],
|
||||
salesMin: row[BLOCK1.cols.sMin],
|
||||
salesMax: row[BLOCK1.cols.sMax],
|
||||
};
|
||||
const rounded = {
|
||||
performanceMin: normalizeThreshold(raw.performanceMin),
|
||||
performanceMax: normalizeThreshold(raw.performanceMax),
|
||||
salesMin: normalizeThreshold(raw.salesMin),
|
||||
salesMax: normalizeThreshold(raw.salesMax),
|
||||
};
|
||||
// 只记录「确实被取整」的溢出值,便于核对(决策 5)
|
||||
Object.entries(raw).forEach(([key, value]) => {
|
||||
const before = Number(value);
|
||||
if (!Number.isFinite(before)) return;
|
||||
if (rounded[key] !== before) {
|
||||
skipped.push({ rowNo, reason: `字段 ${key} 原值 ${value} 疑似计算溢出,已取整为 ${rounded[key]}(决策 5)`, rounding: true });
|
||||
}
|
||||
});
|
||||
list.push({
|
||||
// 配置表里是短名(如「电脑桌/书桌」),稍后会展开成 jst_sku.category 的完整值
|
||||
displayName: category,
|
||||
scaleTag,
|
||||
...rounded,
|
||||
priority: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||
sort: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||
});
|
||||
}
|
||||
return { list, skipped };
|
||||
}
|
||||
|
||||
// 读取 jst_sku.category 的全部真实取值(企划库与业务库同表,优先用可达的企划库)
|
||||
async function loadDbCategories() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.PLANNING_PUBLIC_DB_HOST,
|
||||
port: Number(process.env.PLANNING_PUBLIC_DB_PORT),
|
||||
user: process.env.PLANNING_PUBLIC_DB_USER,
|
||||
password: process.env.PLANNING_PUBLIC_DB_PASSWORD,
|
||||
database: process.env.PLANNING_PUBLIC_DB_NAME,
|
||||
connectTimeout: 20000,
|
||||
});
|
||||
const [rows] = await connection.query(
|
||||
`SELECT TRIM(category) AS category, COUNT(*) AS skuCount
|
||||
FROM jst_sku
|
||||
WHERE category IS NOT NULL AND TRIM(category) <> ''
|
||||
GROUP BY TRIM(category)`
|
||||
);
|
||||
await connection.end();
|
||||
return rows.map((row) => ({
|
||||
full: row.category,
|
||||
short: stripCategoryPrefix(row.category),
|
||||
skuCount: Number(row.skuCount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「配置短名」展开为「数据库完整品类名」。
|
||||
* 一个短名可能对应多个 DB 品类(如「电脑桌/书桌」→ 住宅家具-电脑桌 + 住宅家具-书桌),
|
||||
* 展开后每个 DB 品类生成一行,displayName 保留原短名用于展示分组。
|
||||
*/
|
||||
function expandCategories(configRows, dbCategories) {
|
||||
const matched = [];
|
||||
const ambiguous = [];
|
||||
const missing = [];
|
||||
|
||||
configRows.forEach((row) => {
|
||||
const shortName = row.displayName;
|
||||
|
||||
// 1) 手工映射优先:聚合类目 → DB 类目(家族规则,自动展开子类目)
|
||||
if (MANUAL_CATEGORY_MAP[shortName]) {
|
||||
const rule = MANUAL_CATEGORY_MAP[shortName];
|
||||
let resolved = [];
|
||||
if (Array.isArray(rule)) {
|
||||
// 兼容:显式 DB 短名列表
|
||||
rule.forEach((targetShort) => {
|
||||
const hits = dbCategories.filter((item) => item.short === targetShort);
|
||||
if (hits.length) hits.forEach((hit) => resolved.push(hit));
|
||||
else console.warn(` ⚠️ 手工映射目标「${targetShort}」在 jst_sku 中找不到对应类目,已跳过(请核对 MANUAL_CATEGORY_MAP)`);
|
||||
});
|
||||
} else if (rule && rule.family === 'sofa') {
|
||||
resolved = dbCategories.filter((item) => item.short.includes('沙发') && !rule.exclude.includes(item.short));
|
||||
} else if (rule && rule.family === 'office') {
|
||||
resolved = dbCategories.filter((item) => /^商业\/办公家具\s*-\s*/.test(item.full) && !rule.exclude.includes(item.short));
|
||||
}
|
||||
if (!resolved.length) {
|
||||
missing.push(row);
|
||||
} else {
|
||||
resolved.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 剥离前缀后同名
|
||||
let hits = dbCategories.filter((item) => item.short === shortName);
|
||||
// 3) 包含匹配兜底
|
||||
if (!hits.length) {
|
||||
hits = dbCategories.filter((item) => (
|
||||
item.short.includes(shortName) || shortName.includes(item.short)
|
||||
));
|
||||
}
|
||||
|
||||
if (!hits.length) {
|
||||
missing.push(row);
|
||||
} else {
|
||||
hits.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||
if (hits.length > 1) ambiguous.push({ shortName, targets: hits });
|
||||
}
|
||||
});
|
||||
|
||||
return { matched, ambiguous, missing };
|
||||
}
|
||||
|
||||
function parseSabcMappings(rows) {
|
||||
const list = [];
|
||||
// H 列(规模标签)是纵向合并单元格(H4:H8、H9:H13 …),
|
||||
// XLSX.utils.sheet_to_json 只在合并区左上角单元格保留值,其余为 null,
|
||||
// 因此需要向下填充,否则只能解析出 5 条而不是 25 条。
|
||||
let carriedScaleTag = '';
|
||||
for (let rowNo = BLOCK2.startRow; rowNo <= BLOCK2.endRow; rowNo += 1) {
|
||||
const row = rows[rowNo - 1] || [];
|
||||
const ownScaleTag = text(row[BLOCK2.cols.scaleTag]);
|
||||
if (ownScaleTag) carriedScaleTag = ownScaleTag;
|
||||
|
||||
const scaleTag = carriedScaleTag;
|
||||
const profitLevel = text(row[BLOCK2.cols.profitLevel]);
|
||||
const sabcTag = text(row[BLOCK2.cols.sabc]);
|
||||
if (!scaleTag || !profitLevel || !sabcTag) continue;
|
||||
list.push({ scaleTag, profitLevel, sabcTag, sort: list.length + 1 });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function buildScaleInsertSql(list) {
|
||||
if (!list.length) return '-- (无规模阈值数据)';
|
||||
const values = list.map((row) => ` (${sqlText(row.category)}, ${sqlText(row.displayName)}, ${sqlText(row.scaleTag)}, `
|
||||
+ `${sqlNumber(row.performanceMin)}, ${sqlNumber(row.performanceMax)}, `
|
||||
+ `${sqlNumber(row.salesMin)}, ${sqlNumber(row.salesMax)}, ${row.priority}, ${row.sort}, 1)`);
|
||||
return `INSERT INTO product_scale_thresholds\n`
|
||||
+ ' (category, display_name, scale_tag, performance_min, performance_max, sales_min, sales_max, priority, sort, enabled)\nVALUES\n'
|
||||
+ `${values.join(',\n')}\nON DUPLICATE KEY UPDATE\n`
|
||||
+ ' display_name = VALUES(display_name),\n'
|
||||
+ ' performance_min = VALUES(performance_min), performance_max = VALUES(performance_max),\n'
|
||||
+ ' sales_min = VALUES(sales_min), sales_max = VALUES(sales_max),\n'
|
||||
+ ' priority = VALUES(priority), sort = VALUES(sort), enabled = VALUES(enabled);';
|
||||
}
|
||||
|
||||
const PROFIT_LEVEL_RATE = {
|
||||
一级: 0.18,
|
||||
二级: 0.16,
|
||||
三级: 0.12,
|
||||
四级: 0.08,
|
||||
五级: 0.08,
|
||||
};
|
||||
|
||||
async function writeScaleThresholds(list) {
|
||||
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||
if (!list.length) return 0;
|
||||
const values = [];
|
||||
const placeholders = list.map((row) => {
|
||||
values.push(
|
||||
row.category, row.displayName, row.scaleTag,
|
||||
row.performanceMin, row.performanceMax, row.salesMin, row.salesMax,
|
||||
row.priority, row.sort, 1
|
||||
);
|
||||
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_scale_thresholds
|
||||
(category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function writeProfitLevels() {
|
||||
await database.local.query('DELETE FROM product_profit_levels');
|
||||
const values = [];
|
||||
const placeholders = PROFIT_LEVELS.map((levelCode, index) => {
|
||||
const rate = PROFIT_LEVEL_RATE[levelCode];
|
||||
values.push(levelCode, rate, `营业利润率≤${rate.toFixed(2)}`, index + 1, 1);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return PROFIT_LEVELS.length;
|
||||
}
|
||||
|
||||
async function writeSabcMappings(list) {
|
||||
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||
if (!list.length) return 0;
|
||||
const values = [];
|
||||
const placeholders = list.map((row) => {
|
||||
values.push(row.scaleTag, row.profitLevel, row.sabcTag, row.sort, 1);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (!args.file) {
|
||||
console.error('缺少 --file=<xlsx 路径> 参数');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { sheetName, rows } = readWorkbook(args.file);
|
||||
console.log(`读取工作簿:${args.file}`);
|
||||
console.log(`Sheet:${sheetName},共 ${rows.length} 行`);
|
||||
|
||||
const { list: configRows, skipped } = parseScaleThresholds(rows);
|
||||
const mappings = parseSabcMappings(rows);
|
||||
|
||||
console.log(`\n解析配置表:`);
|
||||
console.log(` 规模阈值配置行:${configRows.length}(按「配置短名 × 规模标签」计)`);
|
||||
console.log(` SABC 映射 :${mappings.length} 条`);
|
||||
|
||||
if (skipped.length) {
|
||||
console.log(`\n跳过 / 修正明细(${skipped.length} 条):`);
|
||||
skipped.forEach((item) => console.log(` 第 ${item.rowNo} 行:${item.reason}`));
|
||||
}
|
||||
|
||||
// ---- 品类名展开:配置短名 → jst_sku.category 完整名 ----
|
||||
const dbCategories = await loadDbCategories();
|
||||
const { matched: thresholds, ambiguous, missing } = expandCategories(configRows, dbCategories);
|
||||
|
||||
console.log(`\n品类名映射:`);
|
||||
console.log(` 数据库品类数:${dbCategories.length}`);
|
||||
console.log(` 展开后阈值行:${thresholds.length}(配置行 ${configRows.length} → DB 品类行)`);
|
||||
if (ambiguous.length) {
|
||||
// 同一个短名会出现在多个规模标签下,按短名去重后再报告
|
||||
const byShortName = new Map();
|
||||
ambiguous.forEach((item) => {
|
||||
if (!byShortName.has(item.shortName)) byShortName.set(item.shortName, item.targets);
|
||||
});
|
||||
console.log(`\n ⚠️ 一对多(${byShortName.size} 个配置名对应多个 DB 品类,已全部归入同一档阈值):`);
|
||||
byShortName.forEach((targets, shortName) => {
|
||||
console.log(` ${shortName}`);
|
||||
targets.forEach((t) => console.log(` → ${t.full} (${t.skuCount} SKU)`));
|
||||
});
|
||||
}
|
||||
if (missing.length) {
|
||||
console.log(`\n ❌ 匹配不到(${missing.length} 个,这些配置行不会生效,需在 MANUAL_CATEGORY_MAP 中补映射):`);
|
||||
[...new Set(missing.map((m) => m.displayName))].forEach((name) => console.log(` - ${name}`));
|
||||
}
|
||||
|
||||
const byTag = SCALE_TAG_ORDER.map((tag) => ({
|
||||
tag,
|
||||
count: thresholds.filter((row) => row.scaleTag === tag).length,
|
||||
}));
|
||||
console.log(`\n 按规模标签分布:${byTag.map((item) => `${item.tag} ${item.count}`).join(' / ')}`);
|
||||
console.log(` 展开后品类数:${new Set(thresholds.map((row) => row.category)).size}`);
|
||||
|
||||
if (args.printSql) {
|
||||
console.log('\n---------- 规模标签阈值 SQL ----------');
|
||||
console.log(buildScaleInsertSql(thresholds));
|
||||
console.log('\n(利润标签与 SABC 映射的 SQL 见 sql/20260911_create_product_tag_config_system_mysql.sql)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('\n--dry-run:未写入数据库。');
|
||||
return;
|
||||
}
|
||||
|
||||
const writtenThresholds = await writeScaleThresholds(thresholds);
|
||||
const writtenLevels = await writeProfitLevels();
|
||||
const writtenMappings = await writeSabcMappings(mappings);
|
||||
console.log('\n✅ 导入完成:');
|
||||
console.log(` product_scale_thresholds : ${writtenThresholds} 行`);
|
||||
console.log(` product_profit_levels : ${writtenLevels} 行`);
|
||||
console.log(` product_sabc_mappings : ${writtenMappings} 条`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => database.close && database.close())
|
||||
.catch((error) => {
|
||||
console.error('导入失败:', error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@ -1,218 +0,0 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const sql = require('mssql');
|
||||
|
||||
const batchSize = Math.max(100, Number.parseInt(process.env.PRODUCT_ANALYSIS_SYNC_BATCH_SIZE || '5000', 10));
|
||||
const targetSchema = /^[A-Za-z_][A-Za-z0-9_]*$/.test(process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA || '')
|
||||
? process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA
|
||||
: 'app';
|
||||
|
||||
const sourceConfig = {
|
||||
server: process.env.JIAN_SUN_DB_HOST,
|
||||
port: Number(process.env.JIAN_SUN_DB_PORT || 1433),
|
||||
database: process.env.JIAN_SUN_DB_NAME || 'JianSun',
|
||||
user: process.env.JIAN_SUN_DB_USER,
|
||||
password: process.env.JIAN_SUN_DB_PASSWORD,
|
||||
options: { encrypt: false, trustServerCertificate: true },
|
||||
connectionTimeout: 120000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||
};
|
||||
|
||||
const targetConfig = {
|
||||
server: process.env.CLOUD_DB_PUBLIC_HOST,
|
||||
port: Number(process.env.CLOUD_DB_PUBLIC_PORT || 1433),
|
||||
database: process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica',
|
||||
user: process.env.CLOUD_DB_PUBLIC_USER,
|
||||
password: process.env.CLOUD_DB_PUBLIC_PASSWORD,
|
||||
options: {
|
||||
encrypt: String(process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true',
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
connectionTimeout: 120000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||
};
|
||||
|
||||
const tables = [
|
||||
{
|
||||
name: 'pdd_DataActivityRecords',
|
||||
createColumns: `
|
||||
[id] int NOT NULL,
|
||||
[shopName] varchar(40) NULL,
|
||||
[createTime] varchar(20) NULL,
|
||||
[business_type_str] varchar(100) NULL,
|
||||
[activity_name] varchar(100) NULL,
|
||||
[activity_id] int NULL,
|
||||
[goods_name] varchar(100) NULL,
|
||||
[goods_id] bigint NULL,
|
||||
[hd_thumb_url] varchar(100) NULL,
|
||||
[enroll_time ] bigint NULL,
|
||||
[min_activity_sku_price] decimal(38, 2) NULL,
|
||||
[max_activity_sku_price] decimal(38, 2) NULL,
|
||||
[activity_goods_start_time] bigint NULL,
|
||||
[activity_goods_end_time] bigint NULL,
|
||||
[status] int NULL`,
|
||||
columns: [
|
||||
['id', sql.Int, false],
|
||||
['shopName', sql.VarChar(40), true],
|
||||
['createTime', sql.VarChar(20), true],
|
||||
['business_type_str', sql.VarChar(100), true],
|
||||
['activity_name', sql.VarChar(100), true],
|
||||
['activity_id', sql.Int, true],
|
||||
['goods_name', sql.VarChar(100), true],
|
||||
['goods_id', sql.BigInt, true],
|
||||
['hd_thumb_url', sql.VarChar(100), true],
|
||||
['enroll_time ', sql.BigInt, true],
|
||||
['min_activity_sku_price', sql.Decimal(38, 2), true],
|
||||
['max_activity_sku_price', sql.Decimal(38, 2), true],
|
||||
['activity_goods_start_time', sql.BigInt, true],
|
||||
['activity_goods_end_time', sql.BigInt, true],
|
||||
['status', sql.Int, true],
|
||||
],
|
||||
indexColumn: 'goods_id',
|
||||
},
|
||||
{
|
||||
name: 'pdd_GoodsQualities',
|
||||
createColumns: `
|
||||
[id] int NOT NULL,
|
||||
[avgDescRevScr1m] float NULL,
|
||||
[qltyRfndOrdrCntRto1m] float NULL,
|
||||
[ptHelpPzRate30d] float NULL,
|
||||
[midRto30d] float NULL,
|
||||
[goodsId] bigint NULL,
|
||||
[displayMallScoreRk] int NULL,
|
||||
[rankAvgDescRevScr1mStage] int NULL,
|
||||
[rankAvgDescRevScr1mStplStage] int NULL,
|
||||
[rankQltyRfndOrdrCntRto1mStage] int NULL,
|
||||
[rankQltyRfndOrdrCntRto1mStplStage] int NULL,
|
||||
[rankPtHelpPzRate30dStage] int NULL,
|
||||
[rankPtHelpPzRate30dStplStage] int NULL,
|
||||
[rankComscoreStage ] int NULL,
|
||||
[rankComscoreStplStage] int NULL,
|
||||
[goodsScoreRkStage] int NULL,
|
||||
[goodsScoreRkStplStage] int NULL,
|
||||
[createTime] varchar(20) NULL,
|
||||
[shopName] varchar(40) NULL`,
|
||||
columns: [
|
||||
['id', sql.Int, false],
|
||||
['avgDescRevScr1m', sql.Float, true],
|
||||
['qltyRfndOrdrCntRto1m', sql.Float, true],
|
||||
['ptHelpPzRate30d', sql.Float, true],
|
||||
['midRto30d', sql.Float, true],
|
||||
['goodsId', sql.BigInt, true],
|
||||
['displayMallScoreRk', sql.Int, true],
|
||||
['rankAvgDescRevScr1mStage', sql.Int, true],
|
||||
['rankAvgDescRevScr1mStplStage', sql.Int, true],
|
||||
['rankQltyRfndOrdrCntRto1mStage', sql.Int, true],
|
||||
['rankQltyRfndOrdrCntRto1mStplStage', sql.Int, true],
|
||||
['rankPtHelpPzRate30dStage', sql.Int, true],
|
||||
['rankPtHelpPzRate30dStplStage', sql.Int, true],
|
||||
['rankComscoreStage ', sql.Int, true],
|
||||
['rankComscoreStplStage', sql.Int, true],
|
||||
['goodsScoreRkStage', sql.Int, true],
|
||||
['goodsScoreRkStplStage', sql.Int, true],
|
||||
['createTime', sql.VarChar(20), true],
|
||||
['shopName', sql.VarChar(40), true],
|
||||
],
|
||||
indexColumn: 'goodsId',
|
||||
},
|
||||
];
|
||||
|
||||
function qualified(schema, tableName) {
|
||||
return `[${schema}].[${tableName}]`;
|
||||
}
|
||||
|
||||
function assertConfig(config, label) {
|
||||
const missing = ['server', 'database', 'user', 'password'].filter((key) => !config[key]);
|
||||
if (missing.length) throw new Error(`${label} missing configuration: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
async function ensureTargetTable(pool, definition) {
|
||||
const tableName = qualified(targetSchema, definition.name);
|
||||
const pkName = `PK_${definition.name}`;
|
||||
const lookupIndex = `IX_${definition.name}_${definition.indexColumn}_createTime`;
|
||||
await pool.request().batch(`
|
||||
IF OBJECT_ID(N'${tableName}', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE ${tableName} (
|
||||
${definition.createColumns},
|
||||
CONSTRAINT [${pkName}] PRIMARY KEY CLUSTERED ([id])
|
||||
);
|
||||
END;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'${tableName}') AND name = N'${lookupIndex}')
|
||||
BEGIN
|
||||
CREATE INDEX [${lookupIndex}] ON ${tableName} ([${definition.indexColumn}], [createTime]);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
function createBulkTable(definition) {
|
||||
const table = new sql.Table(definition.name);
|
||||
table.schema = targetSchema;
|
||||
table.create = false;
|
||||
definition.columns.forEach(([name, type, nullable]) => table.columns.add(name, type, { nullable }));
|
||||
return table;
|
||||
}
|
||||
|
||||
async function syncTable(sourcePool, targetPool, definition) {
|
||||
await ensureTargetTable(targetPool, definition);
|
||||
const sourceName = qualified('dbo', definition.name);
|
||||
const targetName = qualified(targetSchema, definition.name);
|
||||
const columnSql = definition.columns.map(([name]) => `[${name}]`).join(', ');
|
||||
const targetState = await targetPool.request().query(`SELECT COALESCE(MAX([id]), 0) AS maxId FROM ${targetName}`);
|
||||
let lastId = Number(targetState.recordset[0].maxId || 0);
|
||||
let copied = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await sourcePool.request()
|
||||
.input('lastId', sql.Int, lastId)
|
||||
.query(`SELECT TOP (${batchSize}) ${columnSql} FROM ${sourceName} WHERE [id] > @lastId ORDER BY [id]`);
|
||||
const rows = result.recordset || [];
|
||||
if (!rows.length) break;
|
||||
|
||||
const bulkTable = createBulkTable(definition);
|
||||
rows.forEach((row) => bulkTable.rows.add(...definition.columns.map(([name]) => row[name])));
|
||||
await targetPool.request().bulk(bulkTable);
|
||||
lastId = Number(rows[rows.length - 1].id);
|
||||
copied += rows.length;
|
||||
console.log(`[product-analysis-sync] ${definition.name}: copied ${copied}, target max id ${lastId}`);
|
||||
}
|
||||
|
||||
const [sourceStats, targetStats] = await Promise.all([
|
||||
sourcePool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${sourceName}`),
|
||||
targetPool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${targetName}`),
|
||||
]);
|
||||
return { copied, source: sourceStats.recordset[0], target: targetStats.recordset[0] };
|
||||
}
|
||||
|
||||
async function syncProductAnalysisReplica() {
|
||||
assertConfig(sourceConfig, 'JianSun source');
|
||||
assertConfig(targetConfig, 'JSReportReplica target');
|
||||
const [sourcePool, targetPool] = await Promise.all([
|
||||
new sql.ConnectionPool(sourceConfig).connect(),
|
||||
new sql.ConnectionPool(targetConfig).connect(),
|
||||
]);
|
||||
|
||||
try {
|
||||
const summary = {};
|
||||
for (const definition of tables) {
|
||||
summary[definition.name] = await syncTable(sourcePool, targetPool, definition);
|
||||
}
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return summary;
|
||||
} finally {
|
||||
await Promise.all([sourcePool.close(), targetPool.close()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
syncProductAnalysisReplica().catch((error) => {
|
||||
console.error('[product-analysis-sync] failed:', error.stack || error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncProductAnalysisReplica,
|
||||
};
|
||||
@ -1,55 +0,0 @@
|
||||
-- 商品监控配套:部门商品标签规则 + 部门SKU负责人配置
|
||||
-- 目标库:系统库 shujuku3_system(MySQL / MariaDB)
|
||||
-- 创建日期:2026-09-11
|
||||
--
|
||||
-- 说明:
|
||||
-- 1) product_tag_rules 部门标签 G-S/G-A/G-B/G-C 的阈值规则(tag_scope='department')
|
||||
-- 与公司 SABC(tag_scope='company')共用一张表,靠 tag_scope 区分。
|
||||
-- ⚠️ 公司 SABC 目前由「商品标签配置」页的三张表承载,本表仅启用 department 分支。
|
||||
-- 2) department_sku_owners 部门 × SKU/SPU → 负责人 的手工维护映射
|
||||
--
|
||||
-- 本文件与 services/localSchema.js 的 ensureProductMonitoringSchema() 等价,
|
||||
-- 服务启动时会自动 CREATE TABLE IF NOT EXISTS,本文件供 DBA 手工建表使用。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_tag_rules (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
|
||||
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签:G-S/G-A/G-B/G-C(company 分支预留 S/A/B/C/D)',
|
||||
metric_key VARCHAR(64) NOT NULL COMMENT '阈值指标:sales_qty_7d(近7天销售件数)/ sales_amount_7d(近7天销售额)',
|
||||
threshold_min DECIMAL(18,4) NULL COMMENT '区间起点(含)',
|
||||
threshold_max DECIMAL(18,4) NULL COMMENT '区间终点(不含)',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '同 scope 内升序命中即停',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
|
||||
KEY idx_scope_enabled (tag_scope, enabled),
|
||||
KEY idx_metric (metric_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)';
|
||||
|
||||
-- 初始数据:部门标签四档,默认口径为近 7 天销售件数(sales_qty_7d)
|
||||
-- ⚠️ 阈值为占位默认值,需在「系统设置 → 部门商品标签配置」中按业务口径调整后生效。
|
||||
INSERT INTO product_tag_rules (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES
|
||||
('department', 'G-S', 'sales_qty_7d', 500, NULL, 1, 1),
|
||||
('department', 'G-A', 'sales_qty_7d', 200, 500, 2, 1),
|
||||
('department', 'G-B', 'sales_qty_7d', 50, 200, 3, 1),
|
||||
('department', 'G-C', 'sales_qty_7d', 0, 50, 4, 1)
|
||||
ON DUPLICATE KEY UPDATE tag_scope = tag_scope;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS department_sku_owners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
department_id BIGINT UNSIGNED NOT NULL COMMENT '关联 departments.id',
|
||||
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
|
||||
user_id BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id',
|
||||
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人;填值则为该 SPU 的负责人',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
|
||||
KEY idx_dept (department_id),
|
||||
KEY idx_spu (spu),
|
||||
KEY idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置';
|
||||
@ -98,10 +98,6 @@ const {
|
||||
testSelectionFissionAiConfig,
|
||||
} = require('../services/selectionFissionAiSettings');
|
||||
const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService');
|
||||
const {
|
||||
getProductTagConfig,
|
||||
saveProductTagConfig,
|
||||
} = require('../services/productTagConfigService');
|
||||
|
||||
const backfillJobs = new Map();
|
||||
const productCodeImportUpload = multer({
|
||||
@ -1215,40 +1211,6 @@ router.post('/selection-fission-ai-settings/image-generation/test', async (_req,
|
||||
|
||||
|
||||
|
||||
// ==================== 商品标签配置(规模标签 / 利润标签 / SABC 映射) ====================
|
||||
|
||||
router.get('/product-tag-config', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getProductTagConfig() });
|
||||
} catch (error) {
|
||||
console.error('获取商品标签配置失败:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取商品标签配置失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/product-tag-config', async (req, res) => {
|
||||
try {
|
||||
const result = await saveProductTagConfig(req.body || {});
|
||||
const config = await getProductTagConfig();
|
||||
res.json({
|
||||
success: true,
|
||||
message: '商品标签配置已保存到系统数据库',
|
||||
data: { ...config, saved: result },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存商品标签配置失败:', error);
|
||||
// 校验类错误回 400,方便前端直接展示原因
|
||||
const status = /行|重复|不能为空|取值非法|必须小于/.test(error.message || '') ? 400 : (error.statusCode || error.status || 500);
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error.message || '保存商品标签配置失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 1688 专用以图搜图 ====================
|
||||
|
||||
|
||||
|
||||
364
backend/src/api/dashboard.js
Normal file
364
backend/src/api/dashboard.js
Normal file
@ -0,0 +1,364 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
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.DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000);
|
||||
const dashboardCache = new Map();
|
||||
const dashboardInFlight = new Map();
|
||||
|
||||
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 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(`[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' },
|
||||
];
|
||||
}
|
||||
|
||||
router.get('/meta', async (_req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT shopName
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
ORDER BY shopName`,
|
||||
{},
|
||||
{ requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }
|
||||
);
|
||||
res.json({ success: true, data: { platform: 'pdd', shops: rows.map((row) => row.shopName) } });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', 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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
@ -1,268 +0,0 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function safeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeSortField(field) {
|
||||
const allowed = new Set([
|
||||
'id', 'shopName', 'createTime', 'activity_name', 'activity_id',
|
||||
'goods_name', 'goods_id', 'min_activity_sku_price', 'max_activity_sku_price',
|
||||
'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||
]);
|
||||
return allowed.has(field) ? field : 'activity_goods_start_time';
|
||||
}
|
||||
|
||||
function normalizeSortOrder(order) {
|
||||
return String(order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
}
|
||||
|
||||
function escapeColumn(field) {
|
||||
const allowed = new Set([
|
||||
'id', 'shopName', 'createTime', 'business_type_str', 'activity_name', 'activity_id',
|
||||
'goods_name', 'goods_id', 'hd_thumb_url', 'enroll_time', 'min_activity_sku_price',
|
||||
'max_activity_sku_price', 'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||
]);
|
||||
if (!allowed.has(field)) return '[id]';
|
||||
return field === 'enroll_time' ? '[enroll_time]' : `[${field}]`;
|
||||
}
|
||||
|
||||
async function getSecondLatestDate() {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
if (rows.length >= 2) return rows[1].createDate;
|
||||
if (rows.length === 1) return rows[0].createDate;
|
||||
return '';
|
||||
}
|
||||
|
||||
router.get('/latest-date', async (req, res) => {
|
||||
try {
|
||||
const date = await getSecondLatestDate();
|
||||
res.json({ success: true, data: { date } });
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询最新数据日期失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'activity_goods_start_time',
|
||||
sortOrder = 'DESC',
|
||||
shopName,
|
||||
activityType,
|
||||
activityName,
|
||||
activityId,
|
||||
goodsName,
|
||||
goodsId,
|
||||
status,
|
||||
date,
|
||||
} = req.query;
|
||||
|
||||
const currentPage = safeInt(page, 1);
|
||||
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const sortColumn = normalizeSortField(sortBy);
|
||||
const sortDir = normalizeSortOrder(sortOrder);
|
||||
|
||||
const whereParts = [];
|
||||
const params = {};
|
||||
|
||||
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||
if (filterDate) {
|
||||
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||
params.filterDate = filterDate;
|
||||
}
|
||||
|
||||
if (shopName && shopName.trim()) {
|
||||
whereParts.push('[shopName] LIKE @shopName');
|
||||
params.shopName = `%${shopName.trim()}%`;
|
||||
}
|
||||
if (activityType && activityType.trim()) {
|
||||
whereParts.push('[business_type_str] LIKE @activityType');
|
||||
params.activityType = `%${activityType.trim()}%`;
|
||||
}
|
||||
if (activityName && activityName.trim()) {
|
||||
whereParts.push('[activity_name] LIKE @activityName');
|
||||
params.activityName = `%${activityName.trim()}%`;
|
||||
}
|
||||
if (activityId && String(activityId).trim()) {
|
||||
whereParts.push('[activity_id] = @activityId');
|
||||
params.activityId = Number(activityId);
|
||||
}
|
||||
if (goodsName && goodsName.trim()) {
|
||||
whereParts.push('[goods_name] LIKE @goodsName');
|
||||
params.goodsName = `%${goodsName.trim()}%`;
|
||||
}
|
||||
if (goodsId && String(goodsId).trim()) {
|
||||
whereParts.push('[goods_id] = @goodsId');
|
||||
params.goodsId = String(goodsId).trim();
|
||||
}
|
||||
if (status !== undefined && status !== '' && status !== 'all') {
|
||||
whereParts.push('[status] = @status');
|
||||
params.status = Number(status);
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const countResult = await jianSunDb.query(
|
||||
`SELECT COUNT(*) AS total FROM dbo.pdd_DataActivityRecords ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
const dataResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[id],
|
||||
[shopName],
|
||||
[createTime],
|
||||
[business_type_str] AS activityType,
|
||||
[activity_name] AS activityName,
|
||||
[activity_id] AS activityId,
|
||||
[goods_name] AS goodsName,
|
||||
[goods_id] AS goodsId,
|
||||
[hd_thumb_url] AS thumbUrl,
|
||||
[enroll_time] 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,
|
||||
[activity_goods_start_time] AS activityStartTime,
|
||||
[activity_goods_end_time] AS activityEndTime,
|
||||
[status],
|
||||
CASE WHEN [status] = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', [status]) END AS statusLabel
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}
|
||||
ORDER BY ${escapeColumn(sortColumn)} ${sortDir}
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||
{ ...params, offset, limit }
|
||||
);
|
||||
|
||||
const statusCountsResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[status],
|
||||
COUNT(*) AS count
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}
|
||||
GROUP BY [status]
|
||||
ORDER BY [status]`,
|
||||
params
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
let totalAll = 0;
|
||||
statusCountsResult.forEach((row) => {
|
||||
statusCounts[row.status] = row.count;
|
||||
totalAll += row.count;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: dataResult,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: limit,
|
||||
filterDate,
|
||||
statusCounts: { all: totalAll, ...statusCounts },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询活动记录失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { date } = req.query;
|
||||
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||
|
||||
const whereSql = filterDate ? 'WHERE LEFT([createTime], 10) = @filterDate' : '';
|
||||
const params = filterDate ? { filterDate } : {};
|
||||
|
||||
const [row] = await jianSunDb.query(
|
||||
`SELECT
|
||||
COUNT(*) AS totalRecords,
|
||||
SUM(CASE WHEN [status] = 501 THEN 1 ELSE 0 END) AS successCount,
|
||||
COUNT(DISTINCT TRY_CONVERT(varchar(50), [goods_id])) AS goodsCount,
|
||||
COUNT(DISTINCT [shopName]) AS shopCount,
|
||||
COUNT(DISTINCT [activity_id]) AS activityIdCount,
|
||||
COUNT(DISTINCT [business_type_str]) AS activityTypeCount,
|
||||
AVG(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS avgMinPrice,
|
||||
AVG(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS avgMaxPrice,
|
||||
MIN(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS globalMinPrice,
|
||||
MAX(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS globalMaxPrice
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}`,
|
||||
params
|
||||
);
|
||||
|
||||
const stats = row || {};
|
||||
const totalRecords = Number(stats.totalRecords) || 0;
|
||||
const successCount = Number(stats.successCount) || 0;
|
||||
const successRate = totalRecords > 0 ? ((successCount / totalRecords) * 100).toFixed(1) : '0.0';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filterDate,
|
||||
totalRecords,
|
||||
successCount,
|
||||
successRate,
|
||||
goodsCount: Number(stats.goodsCount) || 0,
|
||||
shopCount: Number(stats.shopCount) || 0,
|
||||
activityIdCount: Number(stats.activityIdCount) || 0,
|
||||
activityTypeCount: Number(stats.activityTypeCount) || 0,
|
||||
avgMinPrice: stats.avgMinPrice != null ? Number(stats.avgMinPrice).toFixed(2) : '-',
|
||||
avgMaxPrice: stats.avgMaxPrice != null ? Number(stats.avgMaxPrice).toFixed(2) : '-',
|
||||
globalMinPrice: stats.globalMinPrice != null ? Number(stats.globalMinPrice).toFixed(2) : '-',
|
||||
globalMaxPrice: stats.globalMaxPrice != null ? Number(stats.globalMaxPrice).toFixed(2) : '-',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询汇总数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status-counts', async (req, res) => {
|
||||
try {
|
||||
const result = await jianSunDb.query(
|
||||
`SELECT
|
||||
[status],
|
||||
COUNT(*) AS count
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
GROUP BY [status]
|
||||
ORDER BY [status]`
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
let total = 0;
|
||||
result.forEach((row) => {
|
||||
statusCounts[row.status] = row.count;
|
||||
total += row.count;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { all: total, ...statusCounts },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询状态统计失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,20 +1,9 @@
|
||||
const express = require('express');
|
||||
const { getResolvedUserWithPageAccess } = require('../services/pageAccess');
|
||||
const { getOpsBIncentiveBoard, getOpsBIncentiveBatches, getOpsBIncentiveDaily } = require('../services/opsBIncentiveBoardService');
|
||||
const { getAlibaba1688Product } = require('../services/alibaba1688ProductService');
|
||||
const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry');
|
||||
const router = express.Router();
|
||||
|
||||
async function ensurePlanningUploadAccess(req) {
|
||||
const user = await getResolvedUserWithPageAccess(req.user.sub);
|
||||
if (!user || (user.role !== 'admin' && !user.allowedPageKeys?.includes('ops_b_incentive'))) {
|
||||
const error = new Error('当前账号没有激励看板权限');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async function ensureSelectionFissionAccess(req) {
|
||||
// Selection workflows are available to every authenticated user. They share
|
||||
// this router with template uploads, but must not inherit upload permissions.
|
||||
@ -40,43 +29,4 @@ router.get('/selection-fission/1688-products/:productId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive/batches', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const data = await getOpsBIncentiveBatches();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B激励看板批次列表失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B激励看板批次列表失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const result = await getOpsBIncentiveBoard(req.query || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B拼多多激励看板失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B拼多多激励看板失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive/daily', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const result = await getOpsBIncentiveDaily(req.query || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B拼多多激励每日明细失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B拼多多激励每日明细失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,73 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getMonitoringList,
|
||||
getMonitoringFilters,
|
||||
getDepartmentTagConfig,
|
||||
saveDepartmentTagConfig,
|
||||
getProductOperators,
|
||||
} = require('../services/productMonitoringService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '商品监控请求失败') {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ProductMonitoring]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
// 明细列表(含公司标签 / 部门标签 / 负责人 / 汇总)
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMonitoringList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载商品监控列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 筛选下拉选项
|
||||
router.get('/filters', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMonitoringFilters() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载筛选选项失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||
router.get('/operators', async (req, res) => {
|
||||
try {
|
||||
const sku = req.query?.sku || req.query?.skuoid;
|
||||
if (!sku) {
|
||||
return res.status(400).json({ success: false, error: '缺少 sku 参数' });
|
||||
}
|
||||
return res.json({ success: true, data: { operators: await getProductOperators(sku) } });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '加载运营明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 部门标签 + 负责人配置(系统设置用)
|
||||
router.get('/department-config', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getDepartmentTagConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载部门标签配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/department-config', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await saveDepartmentTagConfig(req.body || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存部门标签配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,417 +0,0 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const middleDb = require('../config/middlePlatformDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function safeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toNum(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function fmtMoney(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '-';
|
||||
}
|
||||
|
||||
function fmtRate(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? (n * 100).toFixed(2) : '-';
|
||||
}
|
||||
|
||||
function fmtNum(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
function getDateRange(period, customStart, customEnd) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (period) {
|
||||
case 'realtime': {
|
||||
const d = new Date(today);
|
||||
return { start: formatDate(d), end: formatDate(d) };
|
||||
}
|
||||
case 'yesterday': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 1);
|
||||
return { start: formatDate(d), end: formatDate(d) };
|
||||
}
|
||||
case 'week': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1));
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'month': {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'last7d': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'last30d': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 29);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'custom': {
|
||||
return {
|
||||
start: customStart || formatDate(today),
|
||||
end: customEnd || formatDate(today),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移)
|
||||
function getPreviousRange(range) {
|
||||
const start = new Date(`${range.start}T00:00:00`);
|
||||
const end = new Date(`${range.end}T00:00:00`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
const prevEnd = new Date(start);
|
||||
prevEnd.setDate(prevEnd.getDate() - 1);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - (lengthDays - 1));
|
||||
return { start: formatDate(prevStart), end: formatDate(prevEnd) };
|
||||
}
|
||||
|
||||
const PROMOTION_SUMMARY_SELECT = `SELECT
|
||||
COUNT(*) AS totalRecords,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv,
|
||||
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||
ELSE 0 END AS avgRoi,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv,
|
||||
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||
ELSE 0 END AS avgNetRoi,
|
||||
SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum,
|
||||
CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum]))
|
||||
ELSE 0 END AS avgCostPerNetOrder
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`;
|
||||
|
||||
async function queryPromotionSummary(range) {
|
||||
const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, {
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
});
|
||||
const stats = row || {};
|
||||
return {
|
||||
totalRecords: toNum(stats.totalRecords),
|
||||
orderSpend: toNum(stats.totalOrderSpend),
|
||||
gmv: toNum(stats.totalGmv),
|
||||
roi: toNum(stats.avgRoi),
|
||||
spend: toNum(stats.totalSpend),
|
||||
netGmv: toNum(stats.totalNetGmv),
|
||||
netRoi: toNum(stats.avgNetRoi),
|
||||
netOrderNum: toNum(stats.totalNetOrderNum),
|
||||
costPerNetOrder: toNum(stats.avgCostPerNetOrder),
|
||||
};
|
||||
}
|
||||
|
||||
// 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null
|
||||
function buildPeriodChanges(current, previous) {
|
||||
const changes = {};
|
||||
Object.keys(current).forEach((key) => {
|
||||
const base = Math.abs(previous[key]);
|
||||
changes[key] = base > 0 ? (current[key] - previous[key]) / base : null;
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function getImageMap(goodsIds) {
|
||||
if (!goodsIds.length) return new Map();
|
||||
try {
|
||||
const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(',');
|
||||
const params = {};
|
||||
goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); });
|
||||
const rows = await middleDb.query(
|
||||
`SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`,
|
||||
params
|
||||
);
|
||||
const map = new Map();
|
||||
rows.forEach((row) => {
|
||||
if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl);
|
||||
});
|
||||
return map;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/latest-date', async (req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || '');
|
||||
res.json({ success: true, data: { date: String(date || '').trim() } });
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询最新数据日期失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/shops', async (req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT [shopName]
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != ''
|
||||
ORDER BY [shopName]`
|
||||
);
|
||||
res.json({ success: true, data: rows.map((r) => r.shopName) });
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询店铺列表失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { date, period, start: customStart, end: customEnd } = req.query;
|
||||
let filterDate = (date && String(date).trim()) || '';
|
||||
let range;
|
||||
|
||||
if (period === 'custom') {
|
||||
range = getDateRange('custom', customStart, customEnd);
|
||||
} else if (period) {
|
||||
range = getDateRange(period);
|
||||
filterDate = '';
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
// 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期)
|
||||
if (!filterDate) {
|
||||
const dateRows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
filterDate = dateRows.length >= 2
|
||||
? String(dateRows[1].createDate || '').trim()
|
||||
: String(dateRows[0]?.createDate || '').trim();
|
||||
}
|
||||
range = { start: filterDate, end: filterDate };
|
||||
}
|
||||
|
||||
const previousRange = getPreviousRange(range);
|
||||
const [current, previous] = await Promise.all([
|
||||
queryPromotionSummary(range),
|
||||
previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filterDate: `${range.start} ~ ${range.end}`,
|
||||
previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '',
|
||||
totalRecords: current.totalRecords,
|
||||
orderSpend: fmtMoney(current.orderSpend),
|
||||
gmv: fmtMoney(current.gmv),
|
||||
roi: fmtNum(current.roi),
|
||||
spend: fmtMoney(current.spend),
|
||||
netGmv: fmtMoney(current.netGmv),
|
||||
netRoi: fmtNum(current.netRoi),
|
||||
netOrderNum: fmtNum(current.netOrderNum),
|
||||
costPerNetOrder: fmtMoney(current.costPerNetOrder),
|
||||
changes: previous ? buildPeriodChanges(current, previous) : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询汇总数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'orderSpend',
|
||||
sortOrder = 'DESC',
|
||||
keyword,
|
||||
shopName,
|
||||
date,
|
||||
period,
|
||||
start: customStart,
|
||||
end: customEnd,
|
||||
} = req.query;
|
||||
|
||||
const currentPage = safeInt(page, 1);
|
||||
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
|
||||
const whereParts = [];
|
||||
const params = {};
|
||||
|
||||
if (period && period !== 'custom') {
|
||||
const range = getDateRange(period);
|
||||
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||
params.startDate = range.start;
|
||||
params.endDate = range.end;
|
||||
} else if (period === 'custom' && customStart && customEnd) {
|
||||
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||
params.startDate = customStart;
|
||||
params.endDate = customEnd;
|
||||
} else {
|
||||
let filterDate = (date && String(date).trim()) || '';
|
||||
if (!filterDate) {
|
||||
const dateRows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
filterDate = dateRows.length >= 2
|
||||
? String(dateRows[1].createDate || '').trim()
|
||||
: String(dateRows[0]?.createDate || '').trim();
|
||||
}
|
||||
if (filterDate) {
|
||||
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||
params.filterDate = filterDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyword && keyword.trim()) {
|
||||
whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)');
|
||||
params.kw = `%${keyword.trim()}%`;
|
||||
}
|
||||
if (shopName && shopName.trim()) {
|
||||
whereParts.push('[shopName] = @shopName');
|
||||
params.shopName = shopName.trim();
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const allowedSort = new Set([
|
||||
'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi',
|
||||
'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount',
|
||||
'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr',
|
||||
]);
|
||||
const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]';
|
||||
const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
const countResult = await jianSunDb.query(
|
||||
`SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
const dataResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[id],
|
||||
[goodsName],
|
||||
[goodsId],
|
||||
[shopName],
|
||||
[createTime],
|
||||
[orderSpend],
|
||||
[gmv],
|
||||
[roi],
|
||||
[spend],
|
||||
[orderNum],
|
||||
[transactionCost],
|
||||
[avgPayAmount],
|
||||
[directGmv],
|
||||
[indirectGmv],
|
||||
[directOrderNum],
|
||||
[indirectOrderNum],
|
||||
[netGmv],
|
||||
[netRoi],
|
||||
[netOrderNum],
|
||||
[netAvgPayAmount],
|
||||
[netRate],
|
||||
[settlementGmv],
|
||||
[settlementRoi],
|
||||
[settlementOrder],
|
||||
[refundGmvRate30d],
|
||||
[refundOrderRate30d],
|
||||
[exemptRefundGmvRate30d],
|
||||
[exemptRefundOrderRate30d],
|
||||
[settlementGmvRate],
|
||||
[settlementOrderRate],
|
||||
[costPerSettlementOrderForDeal],
|
||||
[impression],
|
||||
[click],
|
||||
[ctr],
|
||||
[cvr],
|
||||
[targetRoi],
|
||||
[targetBid]
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
${whereSql}
|
||||
ORDER BY ${sortCol} ${sortDir}
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||
{ ...params, offset, limit }
|
||||
);
|
||||
|
||||
const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean);
|
||||
const imageMap = await getImageMap(goodsIds);
|
||||
|
||||
const list = dataResult.map((row) => ({
|
||||
...row,
|
||||
thumbUrl: imageMap.get(String(row.goodsId)) || '',
|
||||
orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null,
|
||||
gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null,
|
||||
spend: row.spend != null ? toNum(row.spend) / 1000.0 : null,
|
||||
transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null,
|
||||
avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null,
|
||||
directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null,
|
||||
indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null,
|
||||
netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null,
|
||||
netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null,
|
||||
settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null,
|
||||
costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: limit,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询推广数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -14,21 +14,12 @@ const buildPlanningPage = ({ pageKey, pageName, path, allowSubPaths = false }) =
|
||||
});
|
||||
|
||||
const SIDEBAR_PAGES = [
|
||||
buildPlanningPage({ pageKey: 'ops_b_incentive', pageName: '激励看板', path: '/planning/operation/ops-b-incentive' }),
|
||||
buildPlanningPage({
|
||||
pageKey: 'dashboard_preview',
|
||||
pageName: '仪表盘(开发预览)',
|
||||
path: '/planning/dev-style-preview',
|
||||
}),
|
||||
buildPlanningPage({
|
||||
pageKey: 'marketing_events',
|
||||
pageName: '营销活动',
|
||||
path: '/planning/operation/marketing-events',
|
||||
allowSubPaths: true,
|
||||
}),
|
||||
buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }),
|
||||
buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }),
|
||||
buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }),
|
||||
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
|
||||
{
|
||||
pageKey: 'settings',
|
||||
|
||||
@ -29,10 +29,7 @@ const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
|
||||
const channelRoutes = require('./api/channel');
|
||||
const analysisRoutes = require('./api/analysis');
|
||||
const productCodeBoardRoutes = require('./api/productCodeBoard');
|
||||
const productAnalysisRoutes = require('./api/productAnalysis');
|
||||
const marketingEventsRoutes = require('./api/marketingEvents');
|
||||
const promotionModuleRoutes = require('./api/promotionModule');
|
||||
const productMonitoringRoutes = require('./api/productMonitoring');
|
||||
const dashboardRoutes = require('./api/dashboard');
|
||||
const database = require('./config/database');
|
||||
const cloudDb = require('./config/cloudDatabase');
|
||||
const jianSunDb = require('./config/jianSunDatabase');
|
||||
@ -43,8 +40,6 @@ const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductE
|
||||
const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob');
|
||||
const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob');
|
||||
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
|
||||
const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
|
||||
const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
|
||||
const {
|
||||
startAlibaba1688ImageSearchExpiryScheduler,
|
||||
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
|
||||
@ -128,7 +123,6 @@ function shouldHandlePlanningUploadPageCache(req) {
|
||||
req.path === '/'
|
||||
|| req.path.startsWith('/table/may_product_data')
|
||||
|| req.path.startsWith('/analysis/may-channel-insight')
|
||||
|| req.path.startsWith('/analysis/ops-b-incentive')
|
||||
|| req.path.startsWith('/selection-fission')
|
||||
) {
|
||||
return false;
|
||||
@ -646,12 +640,9 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes);
|
||||
cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes);
|
||||
cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes);
|
||||
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
|
||||
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
|
||||
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
|
||||
cubejsApp.use('/api/dashboard', authMiddleware, dashboardRoutes);
|
||||
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
|
||||
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
|
||||
// =====================
|
||||
// 数据分析 API(公开访问,可根据需要添加认证)
|
||||
@ -717,8 +708,6 @@ async function startServer(options = {}) {
|
||||
startPddSkuDimensionSnapshotSchedulers();
|
||||
startPlanningMayProductSnapshotSchedulers();
|
||||
startPlanningDingWorkflowSyncSchedulers();
|
||||
startOpsBIncentiveSnapshotSchedulers();
|
||||
startGoodsFirstSeenScheduler();
|
||||
startAlibaba1688ImageSearchExpiryScheduler();
|
||||
|
||||
// Keep the first selection-pool page request from paying the remote schema
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
const cron = require('node-cron');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const planningDb = require('../services/planningDatabase');
|
||||
const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService');
|
||||
|
||||
const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const SYNC_CHUNK_SIZE = 500;
|
||||
const BACKFILL_CHUNK_SIZE = 800;
|
||||
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
|
||||
|
||||
let schemaEnsured = false;
|
||||
|
||||
async function ensureTable() {
|
||||
if (schemaEnsured) return;
|
||||
await planningDb.query(`
|
||||
CREATE TABLE IF NOT EXISTS pdd_goods_first_seen (
|
||||
goods_id VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||
first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)',
|
||||
last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期',
|
||||
is_onsale TINYINT NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
schemaEnsured = true;
|
||||
}
|
||||
|
||||
function epochToDatetime(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) return null;
|
||||
const ms = num >= 1e12 ? num : num * 1000;
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at)
|
||||
async function syncCaptureDate(captureDate) {
|
||||
await ensureTable();
|
||||
const startedAt = Date.now();
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId,
|
||||
MIN(s.createdAt) AS minCreatedAt,
|
||||
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index))
|
||||
WHERE s.createTime = @captureDate
|
||||
AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> ''
|
||||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`,
|
||||
{ captureDate },
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const records = [];
|
||||
rows.forEach((row) => {
|
||||
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||
if (!createdAt) return;
|
||||
records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]);
|
||||
});
|
||||
|
||||
const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||
for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) {
|
||||
const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE);
|
||||
await planningDb.query(
|
||||
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_capture_date = VALUES(last_capture_date),
|
||||
is_onsale = VALUES(is_onsale)`,
|
||||
[chunk]
|
||||
);
|
||||
}
|
||||
const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||
|
||||
return {
|
||||
captureDate,
|
||||
goodsInBatch: records.length,
|
||||
upserted: records.length,
|
||||
newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)),
|
||||
total: Number(countAfter[0]?.n || 0),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验)
|
||||
async function refreshLatestCapture() {
|
||||
const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10));
|
||||
if (!captureDate) {
|
||||
console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步');
|
||||
return { skipped: true };
|
||||
}
|
||||
return syncCaptureDate(captureDate);
|
||||
}
|
||||
|
||||
// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制)
|
||||
async function backfillAllHistory() {
|
||||
await ensureTable();
|
||||
const startedAt = Date.now();
|
||||
const goodsRows = await planningDb.query(
|
||||
`SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})`
|
||||
);
|
||||
const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))];
|
||||
console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`);
|
||||
|
||||
const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||
let synced = 0;
|
||||
for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) {
|
||||
const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE);
|
||||
const params = {};
|
||||
const placeholders = chunk.map((goodsId, index) => {
|
||||
params[`g${index}`] = Number(goodsId);
|
||||
return `@g${index}`;
|
||||
}).join(', ');
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT s.goodsId AS goodsId,
|
||||
MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt,
|
||||
MAX(s.createTime) AS lastCaptureRaw,
|
||||
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus))
|
||||
WHERE s.goodsId IN (${placeholders})
|
||||
GROUP BY s.goodsId`,
|
||||
params,
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const records = [];
|
||||
rows.forEach((row) => {
|
||||
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||
const lastCapture = epochToDatetime(row.lastCaptureRaw);
|
||||
if (!createdAt) return;
|
||||
records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]);
|
||||
});
|
||||
for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) {
|
||||
const chunk = records.slice(i, i + SYNC_CHUNK_SIZE);
|
||||
await planningDb.query(
|
||||
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_capture_date = VALUES(last_capture_date),
|
||||
is_onsale = VALUES(is_onsale)`,
|
||||
[chunk]
|
||||
);
|
||||
}
|
||||
synced += records.length;
|
||||
console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`);
|
||||
}
|
||||
const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||
const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt };
|
||||
console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function startGoodsFirstSeenScheduler() {
|
||||
const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
refreshLatestCapture().catch((error) => {
|
||||
console.error('[GoodsFirstSeen] 定时同步失败:', error.message);
|
||||
});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler };
|
||||
@ -1,31 +0,0 @@
|
||||
const cron = require('node-cron');
|
||||
const { refreshOpsBIncentiveSnapshots } = require('../services/opsBIncentiveBoardService');
|
||||
|
||||
const JOB_CRON = String(process.env.OPS_B_INCENTIVE_SNAPSHOT_CRON || '0 * * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
async function runOpsBIncentiveSnapshotJob(source = 'scheduled') {
|
||||
try {
|
||||
const result = await refreshOpsBIncentiveSnapshots(source);
|
||||
console.log('[OpsBIncentiveSnapshot] refresh completed:', JSON.stringify(result));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[OpsBIncentiveSnapshot] refresh failed:', error.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startOpsBIncentiveSnapshotSchedulers() {
|
||||
// 默认开启;仅当显式设置 DISABLE_OPS_B_INCENTIVE_SNAPSHOT=true 时关闭。
|
||||
const disabled = String(process.env.DISABLE_OPS_B_INCENTIVE_SNAPSHOT || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] 运营中心B激励看板快照任务已被禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runOpsBIncentiveSnapshotJob('daily_schedule').catch(() => {});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 运营中心B激励看板快照任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { startOpsBIncentiveSnapshotSchedulers, runOpsBIncentiveSnapshotJob };
|
||||
@ -77,8 +77,6 @@ function buildDesktopCapabilities(user) {
|
||||
shopManagement: canPlan,
|
||||
execution: canPlan,
|
||||
logs: true,
|
||||
marketingEvents: isAdmin || allowed.has('marketing_events'),
|
||||
promotion: isAdmin || allowed.has('promotion_module'),
|
||||
productTest: isAdmin || allowed.has('new_product_test'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,11 +7,6 @@ const { ensureDesktopPresenceSchema } = require('./desktopPresenceService');
|
||||
const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService');
|
||||
const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService');
|
||||
const { ensureThirdPartySsoDirectSchema } = require('./thirdPartySsoDirectService');
|
||||
const { ensureProductTagConfigSchema } = require('./productTagConfigService');
|
||||
const {
|
||||
ensureProductMonitoringSchema,
|
||||
warmProductMonitoringDataset,
|
||||
} = require('./productMonitoringService');
|
||||
|
||||
const USER_COLUMNS = [
|
||||
['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"],
|
||||
@ -118,18 +113,6 @@ async function ensureTable(tableName, createSql) {
|
||||
}
|
||||
}
|
||||
|
||||
async function tableExists(tableName) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function indexExists(tableName, indexName) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1
|
||||
@ -255,67 +238,10 @@ async function seedRedisConfigFromEnv() {
|
||||
}
|
||||
|
||||
async function ensureLocalSchema() {
|
||||
await ensureTable(
|
||||
'ops_b_incentive_snapshots',
|
||||
`CREATE TABLE IF NOT EXISTS ops_b_incentive_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
snapshot_key VARCHAR(80) NOT NULL,
|
||||
scope_start DATE NOT NULL,
|
||||
scope_end DATE NOT NULL,
|
||||
metric_end DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||
finalized TINYINT(1) NOT NULL DEFAULT 0,
|
||||
rows_json MEDIUMTEXT NOT NULL,
|
||||
summary_json TEXT NULL,
|
||||
generated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ops_b_snapshot_key (snapshot_key),
|
||||
KEY idx_ops_b_snapshot_period (scope_start, scope_end, metric_end, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
||||
);
|
||||
await ensureTable(
|
||||
'ops_b_incentive_periods',
|
||||
`CREATE TABLE IF NOT EXISTS ops_b_incentive_periods (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scope_start DATE NOT NULL COMMENT '上架日期区间起',
|
||||
scope_end DATE NOT NULL COMMENT '上架日期区间止',
|
||||
pull_date DATE NOT NULL COMMENT '拉取(冻结)日期',
|
||||
fee_cap DECIMAL(5,4) NOT NULL COMMENT '费比达标上限',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
||||
);
|
||||
await ensureTable(
|
||||
'product_analysis_user_prefs',
|
||||
`CREATE TABLE IF NOT EXISTS product_analysis_user_prefs (
|
||||
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
|
||||
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化配置 JSON',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置'`
|
||||
);
|
||||
if (await tableExists('pricing_calculator_user_prefs')) {
|
||||
await database.local.query(
|
||||
`INSERT INTO product_analysis_user_prefs (user_id, prefs_json, created_at, updated_at)
|
||||
SELECT user_id, prefs_json, created_at, updated_at
|
||||
FROM pricing_calculator_user_prefs
|
||||
WHERE JSON_VALID(prefs_json)
|
||||
AND JSON_EXTRACT(prefs_json, '$.productAnalysisSignalThresholds') IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
prefs_json = VALUES(prefs_json),
|
||||
updated_at = VALUES(updated_at)`
|
||||
);
|
||||
}
|
||||
await ensureDesktopPresenceSchema();
|
||||
await ensureDesktopExecutionTaskSchema();
|
||||
await ensureThirdPartySsoSchema();
|
||||
await ensureThirdPartySsoDirectSchema();
|
||||
await ensureProductTagConfigSchema();
|
||||
|
||||
await ensureTable(
|
||||
'auth_configs',
|
||||
@ -951,11 +877,7 @@ async function ensureLocalSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫控价快照读库'`
|
||||
);
|
||||
|
||||
await ensureProductMonitoringSchema();
|
||||
|
||||
await ensureSidebarRolePermissions();
|
||||
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动
|
||||
void warmProductMonitoringDataset();
|
||||
await ensureDepartmentPagePermissions();
|
||||
await cleanupRetiredFeatureSettings(database.local);
|
||||
await repairDuplicateExternalDepartments();
|
||||
|
||||
@ -1,430 +0,0 @@
|
||||
const dayjs = require('dayjs');
|
||||
const database = require('../config/database');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
|
||||
const DEFAULT_SCOPE_START = '2026-07-15';
|
||||
const DEFAULT_SCOPE_END = '2026-07-21';
|
||||
const DEFAULT_END_DATE = '2026-09-02';
|
||||
const AWARD_PER_ID = 400;
|
||||
const JULY_START = '2026-07-01';
|
||||
const JULY_END = '2026-07-31';
|
||||
|
||||
function text(value, fallback = '') {
|
||||
const valueText = String(value ?? '').trim();
|
||||
return valueText || fallback;
|
||||
}
|
||||
|
||||
function validDate(value, fallback) {
|
||||
const valueText = text(value, fallback);
|
||||
return /^\d{4}-\d{2}-\d{2}$/u.test(valueText) && dayjs(valueText).isValid() ? valueText : fallback;
|
||||
}
|
||||
|
||||
function addDays(value, offset) {
|
||||
return dayjs(value).add(offset, 'day').format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function resolveQuery(query = {}) {
|
||||
const scopeStart = validDate(query.scopeStart || query.startDate, DEFAULT_SCOPE_START);
|
||||
const scopeEnd = validDate(query.scopeEnd || query.endDate, DEFAULT_SCOPE_END);
|
||||
const endDate = validDate(query.metricEnd || query.asOfDate, DEFAULT_END_DATE);
|
||||
const metricStart = addDays(endDate, -29);
|
||||
const feeCap = scopeStart >= '2026-07-22' ? 0.88 : 0.9;
|
||||
return {
|
||||
scopeStart,
|
||||
scopeEnd: scopeEnd < scopeStart ? scopeStart : scopeEnd,
|
||||
metricStart,
|
||||
metricEnd: endDate,
|
||||
feeCap,
|
||||
ider: text(query.ider),
|
||||
shopName: text(query.shopName),
|
||||
spu: text(query.spu),
|
||||
goodsId: text(query.goodsId),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeRow(row = {}, query) {
|
||||
const shippedDays30d = Number(row.shippedDays30d || 0);
|
||||
const performance30d = normalizeNumber(row.performance30d);
|
||||
const fee30d = normalizeNumber(row.fee30d);
|
||||
const feeRatio30d = performance30d > 0 && fee30d !== null ? fee30d / performance30d : null;
|
||||
const firstCreatedAt = row.firstCreatedAt ? new Date(row.firstCreatedAt) : null;
|
||||
const listingStart = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||||
? dayjs(firstCreatedAt).startOf('day')
|
||||
: null;
|
||||
const metricEndDay = dayjs(query.metricEnd).startOf('day');
|
||||
const listedCalendarDays = listingStart && metricEndDay.isValid()
|
||||
? Math.max(1, metricEndDay.diff(listingStart, 'day') + 1)
|
||||
: 30;
|
||||
const metricDenominatorDays = Math.min(30, listedCalendarDays);
|
||||
const sellThroughRate30d = shippedDays30d / metricDenominatorDays;
|
||||
const feeQualified = performance30d > 0 && feeRatio30d !== null && feeRatio30d <= query.feeCap;
|
||||
const bonusQualified = feeQualified && sellThroughRate30d > 0.3;
|
||||
const listingDays = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||||
? Math.max(0, dayjs(query.metricEnd).diff(dayjs(firstCreatedAt), 'day'))
|
||||
: null;
|
||||
return {
|
||||
...row,
|
||||
goodsId: text(row.goodsId),
|
||||
ider: text(row.ider),
|
||||
shopName: text(row.shopName),
|
||||
spu: text(row.spu),
|
||||
goodsName: text(row.goodsName),
|
||||
linkType: text(row.linkType),
|
||||
status: Number(row.onsale || 0) === 1 ? '在售' : '已下架',
|
||||
skuCount: Number(row.skuCount || 0),
|
||||
listingDays,
|
||||
julyPerformance: normalizeNumber(row.julyPerformance),
|
||||
julyFee: normalizeNumber(row.julyFee),
|
||||
julyFeeRatio: normalizeNumber(row.julyPerformance) > 0 ? normalizeNumber(row.julyFee) / normalizeNumber(row.julyPerformance) : null,
|
||||
julyGoodsCount: normalizeNumber(row.julyGoodsCount) || 0,
|
||||
julyShippedDays: Number(row.julyShippedDays || 0),
|
||||
julySellThroughRate: Number(row.julyShippedDays || 0) / 31,
|
||||
performance30d,
|
||||
fee30d,
|
||||
feeRatio30d,
|
||||
goodsCount30d: normalizeNumber(row.goodsCount30d) || 0,
|
||||
shippedDays30d,
|
||||
metricDenominatorDays,
|
||||
sellThroughRate30d,
|
||||
feeQualified,
|
||||
sellThroughQualified: sellThroughRate30d > 0.3,
|
||||
bonusQualified,
|
||||
bonus: bonusQualified ? AWARD_PER_ID : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildParams(query) {
|
||||
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||||
return {
|
||||
scopeStart: query.scopeStart,
|
||||
scopeEnd: query.scopeEnd,
|
||||
metricStart: query.metricStart,
|
||||
metricEnd: query.metricEnd,
|
||||
julyStart: JULY_START,
|
||||
julyEnd: JULY_END,
|
||||
ider: query.ider,
|
||||
shopName: query.shopName,
|
||||
spu: query.spu,
|
||||
goodsId: query.goodsId,
|
||||
scopeStartEpochSec: epoch(query.scopeStart),
|
||||
scopeEndEpochSec: epoch(addDays(query.scopeEnd, 1)),
|
||||
scopeStartEpochMs: epoch(query.scopeStart) * 1000,
|
||||
scopeEndEpochMs: epoch(addDays(query.scopeEnd, 1)) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyMetrics() {
|
||||
return { julyPerformance: 0, julyFee: 0, julyGoodsCount: 0, julyShippedDays: 0, performance30d: 0, fee30d: 0, goodsCount30d: 0, shippedDays30d: 0 };
|
||||
}
|
||||
|
||||
async function loadSalesMetrics(goodsIds, query) {
|
||||
const metrics = new Map();
|
||||
const ids = [...new Set((goodsIds || []).map((value) => String(value || '').trim()).filter((value) => /^\d+$/u.test(value)))];
|
||||
for (let offset = 0; offset < ids.length; offset += 1000) {
|
||||
const chunk = ids.slice(offset, offset + 1000);
|
||||
const inList = chunk.map((id) => `'${id}'`).join(',');
|
||||
const rows = await cloudDatabase.query(`
|
||||
SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) AS goodsId,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS julyPerformance,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS julyFee,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS julyGoodsCount,
|
||||
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS julyShippedDays,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS performance30d,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS fee30d,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS goodsCount30d,
|
||||
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS shippedDays30d
|
||||
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||||
WHERE d.item_id IN (${inList})
|
||||
AND d.stat_date BETWEEN @julyStart AND @metricEnd
|
||||
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id)))
|
||||
`, { julyStart: JULY_START, julyEnd: JULY_END, metricStart: query.metricStart, metricEnd: query.metricEnd }, { requestTimeout: 180000 });
|
||||
rows.forEach((row) => metrics.set(String(row.goodsId), row));
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// pdd_Skus 由外部采集系统写入,存在残缺批次(如 2026-09-02 仅 113 个商品)。
|
||||
// 从 upperBound 往回 7 天内挑最新一个商品数不低于窗口最大值 70% 的批次,避免用到不完整采集。
|
||||
async function pickSkuCaptureTime(upperBound) {
|
||||
const end = dayjs(upperBound).isAfter(dayjs(), 'day') ? dayjs().format('YYYY-MM-DD') : dayjs(upperBound).format('YYYY-MM-DD');
|
||||
const dates = Array.from({ length: 7 }, (_, index) => dayjs(end).subtract(index, 'day').format('YYYY-MM-DD'));
|
||||
const counts = await Promise.all(dates.map((captureTime) => jianSunDb.query(
|
||||
'SELECT COUNT(DISTINCT goodsId) AS goodsCount FROM dbo.pdd_Skus WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE createTime = @captureTime',
|
||||
{ captureTime }, { requestTimeout: 60000 }
|
||||
)));
|
||||
const mapped = dates.map((dateValue, index) => ({ date: dateValue, goodsCount: Number(counts[index]?.[0]?.goodsCount || 0) }));
|
||||
const maxCount = Math.max(...mapped.map((item) => item.goodsCount));
|
||||
if (maxCount <= 0) return null;
|
||||
return (mapped.find((item) => item.goodsCount >= maxCount * 0.7) || mapped.find((item) => item.goodsCount === maxCount)).date;
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBoardLive(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const livePeriod = findPeriod(await loadPeriods(), query);
|
||||
if (livePeriod) query.feeCap = livePeriod.feeCap;
|
||||
const targetRows = await jianSunDb.query(`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid))) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.ider)))) AS ider, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), tm.shopname)))) AS shopName, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.spu)))) AS spu, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.linkType)))) AS linkType FROM dbo.op_TargetManagement tm WITH (NOLOCK) WHERE tm.dept = N'运营中心B' GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid)))`, {}, { requestTimeout: 60000 });
|
||||
const latestCreateTime = await pickSkuCaptureTime(query.metricEnd);
|
||||
if (!latestCreateTime) throw new Error('pdd_Skus 近7天无可用完整采集批次');
|
||||
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||||
const startSec = epoch(query.scopeStart);
|
||||
const endSec = epoch(addDays(query.scopeEnd, 1));
|
||||
const skuRows = await jianSunDb.query(`SELECT CONVERT(NVARCHAR(100), s.goodsId) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.goodsName)))) AS goodsName, MIN(DATEADD(SECOND, CONVERT(INT, CASE WHEN s.createdAt >= 1000000000000 THEN s.createdAt / 1000 ELSE s.createdAt END), CONVERT(DATETIME2, '1970-01-01'))) AS firstCreatedAt, MAX(CASE WHEN TRY_CONVERT(INT, s.isOnsale) = 1 THEN 1 ELSE 0 END) AS onsale, COUNT_BIG(1) AS skuCount FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE s.createTime = @latestCreateTime AND ((s.createdAt >= @startSec AND s.createdAt < @endSec) OR (s.createdAt >= @startMs AND s.createdAt < @endMs)) GROUP BY s.goodsId`, { latestCreateTime, startSec, endSec, startMs: startSec * 1000, endMs: endSec * 1000 }, { requestTimeout: 180000 });
|
||||
const targetMap = new Map(targetRows.map((row) => [String(row.goodsId), row]));
|
||||
const scopeRows = skuRows.flatMap((row) => {
|
||||
const target = targetMap.get(String(row.goodsId));
|
||||
if (!target) return [];
|
||||
if ((query.ider && target.ider !== query.ider) || (query.shopName && target.shopName !== query.shopName) || (query.spu && !String(target.spu || '').includes(query.spu)) || (query.goodsId && !String(row.goodsId).includes(query.goodsId))) return [];
|
||||
return [{ ...row, ...target }];
|
||||
});
|
||||
const salesMetrics = await loadSalesMetrics(scopeRows.map((row) => row.goodsId), query);
|
||||
const rows = scopeRows.map((row) => ({ ...emptyMetrics(), ...row, ...(salesMetrics.get(String(row.goodsId)) || {}) }));
|
||||
let dwsLatestRows = [];
|
||||
try {
|
||||
dwsLatestRows = await cloudDatabase.query(`SELECT MAX(TRY_CONVERT(DATE, stat_date)) AS latestDate FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] WITH (NOLOCK) WHERE ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), platform))), N'') IN (N'Pinduoduo', N'拼多多')`, {}, { requestTimeout: 60000 });
|
||||
} catch (error) {
|
||||
console.warn('[OpsBIncentiveBoard] latest DWS date unavailable:', error.message);
|
||||
}
|
||||
const normalizedRows = (rows || []).map((row) => normalizeRow(row, query));
|
||||
const qualifiedRows = normalizedRows.filter((row) => row.bonusQualified);
|
||||
const ownerMap = new Map();
|
||||
normalizedRows.forEach((row) => {
|
||||
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||||
current.goodsIdCount += 1;
|
||||
current.goodsCount30d += row.goodsCount30d;
|
||||
current.shippedDays30d += row.shippedDays30d;
|
||||
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||||
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||||
current.bonus += row.bonus;
|
||||
ownerMap.set(row.ider, current);
|
||||
});
|
||||
return {
|
||||
rows: normalizedRows,
|
||||
bonusRows: qualifiedRows,
|
||||
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||||
summary: {
|
||||
goodsIdCount: normalizedRows.length,
|
||||
feeQualifiedCount: normalizedRows.filter((row) => row.feeQualified).length,
|
||||
sellThroughQualifiedCount: normalizedRows.filter((row) => row.sellThroughQualified).length,
|
||||
bonusQualifiedCount: qualifiedRows.length,
|
||||
bonus: qualifiedRows.length * AWARD_PER_ID,
|
||||
awardPerId: AWARD_PER_ID,
|
||||
},
|
||||
query: { ...query, julyStart: JULY_START, julyEnd: JULY_END, latestDwsDate: dwsLatestRows?.[0]?.latestDate || null },
|
||||
};
|
||||
}
|
||||
|
||||
// 口径来源:运营中心B激励方案批次表。pullDate = 拉取(冻结)日期,统计窗口固定为 pullDate 往前 30 天。
|
||||
// 批次配置存 MySQL ops_b_incentive_periods 表(可动态新增批次,无需发版),此列表仅作首次种子与兜底。
|
||||
const DEFAULT_SNAPSHOT_PERIODS = [
|
||||
{ scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 },
|
||||
{ scopeStart: '2026-07-22', scopeEnd: '2026-07-28', pullDate: '2026-09-05', feeCap: 0.88 },
|
||||
{ scopeStart: '2026-07-29', scopeEnd: '2026-08-04', pullDate: '2026-09-12', feeCap: 0.875 },
|
||||
{ scopeStart: '2026-08-05', scopeEnd: '2026-08-11', pullDate: '2026-09-19', feeCap: 0.87 },
|
||||
{ scopeStart: '2026-08-12', scopeEnd: '2026-08-18', pullDate: '2026-09-26', feeCap: 0.865 },
|
||||
{ scopeStart: '2026-08-19', scopeEnd: '2026-08-25', pullDate: '2026-10-03', feeCap: 0.86 },
|
||||
{ scopeStart: '2026-08-26', scopeEnd: '2026-09-01', pullDate: '2026-10-10', feeCap: 0.855 },
|
||||
{ scopeStart: '2026-09-02', scopeEnd: '2026-09-08', pullDate: '2026-10-17', feeCap: 0.85 },
|
||||
{ scopeStart: '2026-09-09', scopeEnd: '2026-09-15', pullDate: '2026-10-24', feeCap: 0.845 },
|
||||
{ scopeStart: '2026-09-16', scopeEnd: '2026-09-22', pullDate: '2026-10-31', feeCap: 0.84 },
|
||||
];
|
||||
|
||||
function makePeriodKey(scopeStart, scopeEnd) {
|
||||
return `ops-b-${scopeStart.replace(/-/g, '')}-${scopeEnd.replace(/-/g, '')}`;
|
||||
}
|
||||
|
||||
let periodsCache = null;
|
||||
let periodsCacheExpiresAt = 0;
|
||||
|
||||
async function loadPeriods() {
|
||||
if (periodsCache && Date.now() < periodsCacheExpiresAt) return periodsCache;
|
||||
let rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||||
if (!rows.length) {
|
||||
for (const period of DEFAULT_SNAPSHOT_PERIODS) {
|
||||
await database.local.query(
|
||||
'INSERT IGNORE INTO ops_b_incentive_periods (scope_start, scope_end, pull_date, fee_cap, status) VALUES (?, ?, ?, ?, ?)',
|
||||
[period.scopeStart, period.scopeEnd, period.pullDate, period.feeCap, 'active']
|
||||
);
|
||||
}
|
||||
rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||||
}
|
||||
periodsCache = rows
|
||||
.filter((row) => String(row.status || 'active') === 'active')
|
||||
.map((row) => ({
|
||||
key: makePeriodKey(dayjs(row.scope_start).format('YYYY-MM-DD'), dayjs(row.scope_end).format('YYYY-MM-DD')),
|
||||
scopeStart: dayjs(row.scope_start).format('YYYY-MM-DD'),
|
||||
scopeEnd: dayjs(row.scope_end).format('YYYY-MM-DD'),
|
||||
pullDate: dayjs(row.pull_date).format('YYYY-MM-DD'),
|
||||
feeCap: Number(row.fee_cap),
|
||||
}));
|
||||
periodsCacheExpiresAt = Date.now() + 60000;
|
||||
return periodsCache;
|
||||
}
|
||||
|
||||
function findPeriod(periods, query) {
|
||||
return periods.find((period) => period.scopeStart === query.scopeStart && period.scopeEnd === query.scopeEnd) || null;
|
||||
}
|
||||
|
||||
async function readSnapshot(period) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT rows_json, summary_json, metric_end, finalized, generated_at FROM ops_b_incentive_snapshots WHERE snapshot_key = ? AND status = ? LIMIT 1',
|
||||
[period.key, 'completed']
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
try {
|
||||
const meta = JSON.parse(row.summary_json || '{}');
|
||||
return { rows: JSON.parse(row.rows_json || '[]'), summary: meta.summary || {}, ownerSummary: meta.ownerSummary || [], metricEnd: row.metric_end, finalized: Boolean(row.finalized), generatedAt: row.generated_at };
|
||||
} catch (error) {
|
||||
console.warn('[OpsBIncentiveSnapshot] invalid snapshot:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshot(period, query, result, finalized) {
|
||||
await database.local.query(`
|
||||
INSERT INTO ops_b_incentive_snapshots
|
||||
(snapshot_key, scope_start, scope_end, metric_end, status, finalized, rows_json, summary_json, generated_at, completed_at, error_message)
|
||||
VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, NOW(), NOW(), NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
metric_end = VALUES(metric_end), finalized = VALUES(finalized), rows_json = VALUES(rows_json), summary_json = VALUES(summary_json),
|
||||
generated_at = VALUES(generated_at), completed_at = VALUES(completed_at), status = 'completed', error_message = NULL
|
||||
`, [period.key, query.scopeStart, query.scopeEnd, query.metricEnd, finalized ? 1 : 0, JSON.stringify(result.rows || []), JSON.stringify({ summary: result.summary || {}, ownerSummary: result.ownerSummary || [] })]);
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBoard(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const period = findPeriod(await loadPeriods(), query);
|
||||
if (period) query.feeCap = period.feeCap;
|
||||
if (!period) return getOpsBIncentiveBoardLive(input);
|
||||
const existing = await readSnapshot(period);
|
||||
if (!existing) {
|
||||
return {
|
||||
pending: true,
|
||||
rows: [],
|
||||
bonusRows: [],
|
||||
ownerSummary: [],
|
||||
summary: {},
|
||||
query: { ...query, latestDwsDate: null, snapshot: { cached: false, finalized: false, pending: true } },
|
||||
};
|
||||
}
|
||||
const filteredRows = existing.rows.filter((row) => (
|
||||
(!query.ider || row.ider === query.ider)
|
||||
&& (!query.shopName || row.shopName === query.shopName)
|
||||
&& (!query.spu || String(row.spu || '').includes(query.spu))
|
||||
&& (!query.goodsId || String(row.goodsId || '').includes(query.goodsId))
|
||||
));
|
||||
const ownerMap = new Map();
|
||||
filteredRows.forEach((row) => {
|
||||
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||||
current.goodsIdCount += 1;
|
||||
current.goodsCount30d += Number(row.goodsCount30d || 0);
|
||||
current.shippedDays30d += Number(row.shippedDays30d || 0);
|
||||
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||||
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||||
current.bonus += Number(row.bonus || 0);
|
||||
ownerMap.set(row.ider, current);
|
||||
});
|
||||
const bonusRows = filteredRows.filter((row) => row.bonusQualified);
|
||||
return {
|
||||
...existing,
|
||||
rows: filteredRows,
|
||||
bonusRows,
|
||||
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||||
summary: {
|
||||
goodsIdCount: filteredRows.length,
|
||||
feeQualifiedCount: filteredRows.filter((row) => row.feeQualified).length,
|
||||
sellThroughQualifiedCount: filteredRows.filter((row) => row.sellThroughQualified).length,
|
||||
bonusQualifiedCount: bonusRows.length,
|
||||
bonus: bonusRows.reduce((sum, row) => sum + Number(row.bonus || 0), 0),
|
||||
awardPerId: AWARD_PER_ID,
|
||||
},
|
||||
query: {
|
||||
...query,
|
||||
metricEnd: dayjs(existing.metricEnd).format('YYYY-MM-DD'),
|
||||
latestDwsDate: null,
|
||||
snapshot: { cached: true, finalized: existing.finalized, generatedAt: existing.generatedAt },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshOpsBIncentiveSnapshots(source = 'scheduled') {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const results = [];
|
||||
for (const period of await loadPeriods()) {
|
||||
const { scopeStart, scopeEnd } = period;
|
||||
// 拉取日次日首刷才定档,确保统计窗口包含拉取日全天数据(数仓滞后约一天)。
|
||||
const finalized = today > period.pullDate;
|
||||
const existing = await readSnapshot(period);
|
||||
if (existing?.finalized) {
|
||||
results.push({ key: period.key, rows: existing.rows.length, finalized: true, skipped: true, source });
|
||||
continue;
|
||||
}
|
||||
const result = await getOpsBIncentiveBoardLive({ scopeStart, scopeEnd, metricEnd: period.pullDate });
|
||||
await writeSnapshot(period, { scopeStart, scopeEnd, metricEnd: period.pullDate }, result, finalized);
|
||||
results.push({ key: period.key, rows: result.rows.length, finalized, source });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBatches() {
|
||||
const periods = await loadPeriods();
|
||||
const snapshots = await database.local.query(
|
||||
"SELECT snapshot_key, finalized, generated_at, JSON_EXTRACT(summary_json, '$.summary.goodsIdCount') goodsIdCount, JSON_EXTRACT(summary_json, '$.summary.bonusQualifiedCount') bonusQualifiedCount, JSON_EXTRACT(summary_json, '$.summary.bonus') bonus FROM ops_b_incentive_snapshots WHERE status = 'completed'"
|
||||
);
|
||||
const snapshotMap = new Map(snapshots.map((row) => [row.snapshot_key, row]));
|
||||
return periods.map((period, index) => {
|
||||
const snapshot = snapshotMap.get(period.key) || {};
|
||||
return {
|
||||
...period,
|
||||
batchNo: index + 1,
|
||||
finalized: Boolean(snapshot.finalized),
|
||||
generatedAt: snapshot.generated_at || null,
|
||||
goodsIdCount: Number(snapshot.goodsIdCount || 0),
|
||||
bonusQualifiedCount: Number(snapshot.bonusQualifiedCount || 0),
|
||||
bonus: Number(snapshot.bonus || 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveDaily(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const params = buildParams(query);
|
||||
if (!query.goodsId) return { rows: [], query };
|
||||
const rows = await cloudDatabase.query(`
|
||||
WITH daily AS (
|
||||
SELECT TRY_CONVERT(DATE, d.stat_date) AS statDate,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.goods_count), 0)) AS goodsCount,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_performance), 0)) AS realPerformance,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_conclude_fee), 0)) AS realFee
|
||||
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||||
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) = @goodsId
|
||||
AND TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd
|
||||
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||||
GROUP BY TRY_CONVERT(DATE, d.stat_date)
|
||||
) SELECT statDate, goodsCount, CASE WHEN goodsCount > 0 THEN N'是' ELSE N'否' END AS isActive, realPerformance, realFee
|
||||
FROM daily ORDER BY statDate ASC
|
||||
`, params, { requestTimeout: 120000 });
|
||||
return { rows: rows || [], query };
|
||||
}
|
||||
|
||||
module.exports = { getOpsBIncentiveBoard, getOpsBIncentiveBoardLive, getOpsBIncentiveDaily, getOpsBIncentiveBatches, refreshOpsBIncentiveSnapshots, resolveQuery, pickSkuCaptureTime, AWARD_PER_ID, DEFAULT_SNAPSHOT_PERIODS };
|
||||
@ -1,943 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 商品监控:全公司在售商品的基础档案 + 库存 + 标签分级 + 部门运营指标
|
||||
//
|
||||
// 数据来源(2026-09-11 实测确认):
|
||||
// 主表 业务库 rpt.pm_stockMonitor —— 脑图「商品监控」的原始数据源
|
||||
// (5.6 万行 / 日更 / 含 sevenAvgSale 日销、jsStock 库存、jsUseDays 可用天数、
|
||||
// suggestStock 建议备货、purchase 采购负责人、supplier、warehouse、sku_idStatus)
|
||||
// 补充 业务库 rpt.jst_sku —— 图片、条码(sku_code)、材质、颜色、尺寸
|
||||
// 配置 系统库 product_scale_thresholds / product_profit_levels / product_sabc_mappings
|
||||
// (公司 SABC,由「系统设置 → 商品标签配置」维护)
|
||||
// 配置 系统库 product_tag_rules (部门标签 G-S/G-A/G-B/G-C)
|
||||
// 配置 系统库 department_sku_owners (部门 SKU 负责人)
|
||||
//
|
||||
// 判定口径:
|
||||
// - 公司标签 S/A/B/C/D ← productTagConfigService.resolveProductTags()(业绩 + 日销双命中)
|
||||
// - 部门标签 G-S/G-A/G-B/G-C ← product_tag_rules(tag_scope='department'),左闭右开,升序命中即停
|
||||
//
|
||||
// ⚠️ 已知留空字段(等业务给源表后再填):
|
||||
// - 在途库存 inTransitStock → null,前端显示 `--`
|
||||
// - 部门未发货数量 departmentUnshipped → null,前端显示 `--`
|
||||
// (pm_stockMonitor.outOfStock 语义未确认,本次不用)
|
||||
|
||||
const database = require('../config/database');
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const productTagConfigService = require('./productTagConfigService');
|
||||
|
||||
const REPORT_SOURCE_TABLE = 'dbo.pm_stockMonitor';
|
||||
// 销售明细(最新,2026-09-09):按 skuoid 聚合可得业绩/销售额、部门、主渠道、主运营
|
||||
const SALES_DETAIL_TABLE = 'dbo.dws_sn_sales_full_info_di';
|
||||
const SALES_WINDOW_DAYS = 7;
|
||||
// 公司标签 S/A/B/C 的「业绩」阈值(系统设置 → 商品标签配置)是 30 天量级
|
||||
// (如「住宅家具 - 书架」爆款线 48000),因此标签判定用 30 天销售额,日销仍用宽表 sevenAvgSale
|
||||
const SALES_PERFORMANCE_WINDOW_DAYS = 30;
|
||||
|
||||
const SALE_STATUS_OPTIONS = Object.freeze(['销售', '停产', '退市']);
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
const DEPARTMENT_TAG_SCOPE = 'department';
|
||||
const DEPARTMENT_TAG_LEVELS = Object.freeze(['G-S', 'G-A', 'G-B', 'G-C']);
|
||||
const DEPARTMENT_METRIC_KEYS = Object.freeze(['sales_qty_7d', 'sales_amount_7d']);
|
||||
|
||||
// ==================== 表结构 ====================
|
||||
|
||||
async function ensureTable(tableName, createSql) {
|
||||
await database.local.query(createSql);
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`Table ${tableName} create failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProductMonitoringSchema() {
|
||||
await ensureTable(
|
||||
'product_tag_rules',
|
||||
`CREATE TABLE IF NOT EXISTS product_tag_rules (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
|
||||
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签 G-S/G-A/G-B/G-C',
|
||||
metric_key VARCHAR(64) NOT NULL COMMENT 'sales_qty_7d | sales_amount_7d',
|
||||
threshold_min DECIMAL(18,4) NULL,
|
||||
threshold_max DECIMAL(18,4) NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
|
||||
KEY idx_scope_enabled (tag_scope, enabled),
|
||||
KEY idx_metric (metric_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'department_sku_owners',
|
||||
`CREATE TABLE IF NOT EXISTS department_sku_owners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
department_id BIGINT UNSIGNED NOT NULL,
|
||||
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
|
||||
KEY idx_dept (department_id),
|
||||
KEY idx_spu (spu),
|
||||
KEY idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'`
|
||||
);
|
||||
|
||||
// 部门标签默认四档(不存在时播种,口径:近 7 天销售件数)
|
||||
const existing = await database.local.query(
|
||||
'SELECT COUNT(*) AS c FROM product_tag_rules WHERE tag_scope = ?',
|
||||
[DEPARTMENT_TAG_SCOPE]
|
||||
);
|
||||
if (Number(existing[0]?.c || 0) === 0) {
|
||||
const seeds = [
|
||||
['G-S', 500, null, 1],
|
||||
['G-A', 200, 500, 2],
|
||||
['G-B', 50, 200, 3],
|
||||
['G-C', 0, 50, 4],
|
||||
];
|
||||
for (const [level, min, max, priority] of seeds) {
|
||||
await database.local.query(
|
||||
`INSERT INTO product_tag_rules
|
||||
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES (?, ?, 'sales_qty_7d', ?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE tag_scope = tag_scope`,
|
||||
[DEPARTMENT_TAG_SCOPE, level, min, max, priority]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 配置读取 ====================
|
||||
|
||||
async function loadDepartmentTagRules() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, tag_level, metric_key, threshold_min, threshold_max, priority, enabled
|
||||
FROM product_tag_rules
|
||||
WHERE tag_scope = ? AND enabled = 1
|
||||
ORDER BY priority ASC, id ASC`,
|
||||
[DEPARTMENT_TAG_SCOPE]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
tagLevel: row.tag_level,
|
||||
metricKey: row.metric_key,
|
||||
thresholdMin: row.threshold_min === null ? null : Number(row.threshold_min),
|
||||
thresholdMax: row.threshold_max === null ? null : Number(row.threshold_max),
|
||||
priority: Number(row.priority || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
// 公司 SKU 负责人:取自 rpt.V_caterAndOper.oper,**SPU 粒度**(方案第 12 条已确认接受该粒度)
|
||||
async function loadCompanyOwnerIndex() {
|
||||
try {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT spu, oper FROM dbo.V_caterAndOper WHERE oper IS NOT NULL AND oper <> N''`
|
||||
);
|
||||
const index = new Map();
|
||||
rows.forEach((row) => {
|
||||
const spu = String(row.spu || '').trim();
|
||||
const oper = String(row.oper || '').trim();
|
||||
if (spu && oper && !index.has(spu)) index.set(spu, oper);
|
||||
});
|
||||
return index;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载公司SKU负责人失败,该列将留空:', error.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartmentSkuOwners() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT o.department_id, o.owner_type, o.user_id, o.spu, u.name AS user_name, d.name AS department_name
|
||||
FROM department_sku_owners o
|
||||
LEFT JOIN users u ON u.id = o.user_id
|
||||
LEFT JOIN departments d ON d.id = o.department_id`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
departmentId: row.department_id,
|
||||
departmentName: row.department_name,
|
||||
ownerType: row.owner_type,
|
||||
userId: row.user_id,
|
||||
ownerName: row.user_name,
|
||||
spu: row.spu,
|
||||
}));
|
||||
}
|
||||
|
||||
// 部门标签判定:左闭右开 [min, max),按 priority 升序命中即停;未命中返回 null
|
||||
function matchDepartmentTag(rules, salesQty7d) {
|
||||
if (!rules.length) return null;
|
||||
// ⚠️ 无销售记录(null)不等于「销量 0」:Number(null) 是 0,会把无数据行误判成最低档
|
||||
if (salesQty7d === null || salesQty7d === undefined || salesQty7d === '') return null;
|
||||
const value = Number(salesQty7d);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
for (const rule of rules) {
|
||||
const geMin = rule.thresholdMin === null || value >= rule.thresholdMin;
|
||||
const ltMax = rule.thresholdMax === null || value < rule.thresholdMax;
|
||||
if (geMin && ltMax) return rule.tagLevel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 业务库查询 ====================
|
||||
|
||||
// 可排序字段(排序在内存完成,直接用行字段名)
|
||||
const DERIVED_SORT_FIELDS = new Set([
|
||||
'skuId', 'spu', 'category', 'productName', 'supplier', 'warehouse', 'purchaser', 'saleStatus',
|
||||
'companyOwner', 'companySales7d', 'availableStock', 'stockDays', 'suggestedReplenish',
|
||||
'companyTag', 'department', 'departmentTag', 'departmentOwner',
|
||||
'performance7d', 'performance30d', 'departmentSales7d', 'mainSalesChannel', 'mainSalesOperator',
|
||||
]);
|
||||
|
||||
async function getLatestReportDate() {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(date) AS latestDate FROM ${REPORT_SOURCE_TABLE}`
|
||||
);
|
||||
const latest = rows[0]?.latestDate;
|
||||
if (!latest) {
|
||||
throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期');
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
// 全量基础行:pm_stockMonitor 主表 + jst_sku 补图片/条码/材质/颜色/尺寸
|
||||
//
|
||||
// ⚠️ 只按统计日过滤、不带任何业务条件——结果会被 buildDataset 缓存,
|
||||
// 业务条件(状态/品类/SPU/SKU/供应商…)与标签过滤都在内存里做,见 applyBaseFilters。
|
||||
async function queryAllReportRows(latestDate) {
|
||||
const params = { latestDate };
|
||||
|
||||
const dataSql = `
|
||||
SELECT
|
||||
m.sku_id AS skuId,
|
||||
m.sku AS skuCode,
|
||||
m.spu AS spu,
|
||||
m.skuName AS productName,
|
||||
m.category AS category,
|
||||
m.supplier AS supplier,
|
||||
m.warehouse AS warehouse,
|
||||
m.purchase AS purchaser,
|
||||
m.area AS area,
|
||||
m.cooperation AS cooperation,
|
||||
m.brand AS brand,
|
||||
m.sku_idStatus AS saleStatus,
|
||||
m.jsStock AS availableStock,
|
||||
m.jsUseDays AS stockDays,
|
||||
m.sevenAvgSale AS companySales7d,
|
||||
m.suggestStock AS suggestedReplenish,
|
||||
m.produceCycle AS produceCycle,
|
||||
m.safetyDays AS safetyDays,
|
||||
s.sku_code AS barcode,
|
||||
s.color AS colorRaw,
|
||||
s.properties_value AS sizeRaw,
|
||||
COALESCE(NULLIF(s.other_10, N''), NULLIF(s.other_2, N'')) AS material,
|
||||
COALESCE(s.pic_big, s.pic) AS imageUrl
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
LEFT JOIN dbo.jst_sku s ON s.sku_id = m.sku_id
|
||||
WHERE m.date = @latestDate
|
||||
`;
|
||||
|
||||
const rows = await cloudDatabase.query(dataSql, params, { requestTimeout: 180000 });
|
||||
|
||||
return {
|
||||
rows: rows.map((row) => ({
|
||||
skuId: row.skuId,
|
||||
skuCode: row.skuCode || row.skuId,
|
||||
spu: row.spu,
|
||||
barcode: row.barcode || row.skuId,
|
||||
imageUrl: row.imageUrl || null,
|
||||
category: row.category,
|
||||
productName: row.productName,
|
||||
material: row.material || null,
|
||||
color: row.colorRaw || null,
|
||||
size: row.sizeRaw || null,
|
||||
saleStatus: row.saleStatus,
|
||||
supplier: row.supplier || null,
|
||||
warehouse: row.warehouse || null,
|
||||
purchaser: row.purchaser || null,
|
||||
area: row.area || null,
|
||||
cooperation: row.cooperation || null,
|
||||
brand: row.brand || null,
|
||||
companySales7d: toNumberOrNull(row.companySales7d),
|
||||
availableStock: toNumberOrNull(row.availableStock),
|
||||
inTransitStock: null, // 待业务提供源表
|
||||
stockDays: toNumberOrNull(row.stockDays),
|
||||
suggestedReplenish: toNumberOrNull(row.suggestedReplenish),
|
||||
produceCycle: toNumberOrNull(row.produceCycle),
|
||||
safetyDays: toNumberOrNull(row.safetyDays),
|
||||
departmentUnshipped: null, // 待业务确认 outOfStock 语义
|
||||
})),
|
||||
latestDate,
|
||||
};
|
||||
}
|
||||
|
||||
function toNumberOrNull(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
// ==================== 汇总统计 ====================
|
||||
|
||||
// 汇总基于「过滤后的最终结果」统计,保证与列表口径一致
|
||||
function buildSummary(rows = []) {
|
||||
let outOfStockCount = 0;
|
||||
let lowStockDaysCount = 0;
|
||||
let totalSuggestedReplenish = 0;
|
||||
let needReplenishCount = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const stock = row.availableStock === null ? 0 : Number(row.availableStock);
|
||||
const days = row.stockDays === null ? 0 : Number(row.stockDays);
|
||||
if (stock <= 0) outOfStockCount += 1;
|
||||
else if (days < 7) lowStockDaysCount += 1;
|
||||
|
||||
// ⚠️ suggestStock 是有符号的净需求(库存过剩为负数),合计只累计正值,避免负负相加失真
|
||||
const replenish = row.suggestedReplenish === null ? 0 : Number(row.suggestedReplenish);
|
||||
if (replenish > 0) {
|
||||
totalSuggestedReplenish += replenish;
|
||||
needReplenishCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
skuCount: rows.length,
|
||||
outOfStockCount,
|
||||
lowStockDaysCount,
|
||||
totalSuggestedReplenish: Math.round(totalSuggestedReplenish),
|
||||
needReplenishCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 销售明细聚合(业绩 / 部门 / 主渠道 / 主运营) ====================
|
||||
|
||||
// 取销售明细表的最新统计日
|
||||
async function getLatestSalesDate() {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(stat_date) AS latestDate FROM ${SALES_DETAIL_TABLE}`
|
||||
);
|
||||
return rows[0]?.latestDate || null;
|
||||
}
|
||||
|
||||
// 按 skuoid 聚合近 7 天:销售额(业绩口径)、部门、主销售渠道、主销售运营
|
||||
async function loadSalesAggregatesBySku() {
|
||||
try {
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return new Map();
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT
|
||||
d.skuoid AS skuoid,
|
||||
SUM(d.sales_amount) AS salesAmount30d,
|
||||
SUM(d.goods_count) AS salesQty30d,
|
||||
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.sales_amount ELSE 0 END) AS salesAmount7d,
|
||||
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.goods_count ELSE 0 END) AS salesQty7d,
|
||||
MAX(d.profit_center) AS department,
|
||||
MAX(d.shop_dept) AS shopDept
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@perfDays, @latestDate)
|
||||
AND d.skuoid IS NOT NULL AND d.skuoid <> N''
|
||||
GROUP BY d.skuoid`,
|
||||
{ days: SALES_WINDOW_DAYS, perfDays: SALES_PERFORMANCE_WINDOW_DAYS, latestDate },
|
||||
{ requestTimeout: 180000 }
|
||||
);
|
||||
|
||||
const index = new Map();
|
||||
rows.forEach((row) => {
|
||||
const key = String(row.skuoid || '').trim();
|
||||
if (!key) return;
|
||||
index.set(key, {
|
||||
salesAmount7d: toNumberOrNull(row.salesAmount7d),
|
||||
salesQty7d: toNumberOrNull(row.salesQty7d),
|
||||
salesAmount30d: toNumberOrNull(row.salesAmount30d),
|
||||
salesQty30d: toNumberOrNull(row.salesQty30d),
|
||||
department: row.department || row.shopDept || null,
|
||||
});
|
||||
});
|
||||
return index;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载销售明细聚合失败,业绩/部门列将留空:', error.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// 主销售渠道 / 主销售运营:近 7 天销售额最高的渠道与运营(脑图口径)
|
||||
async function loadMainChannelAndOperator(skuoids) {
|
||||
const result = new Map();
|
||||
if (!Array.isArray(skuoids) || !skuoids.length) return result;
|
||||
|
||||
try {
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return result;
|
||||
|
||||
// 仅对本页 SKU 查询,避免全表聚合
|
||||
const list = [...new Set(skuoids.filter(Boolean))].slice(0, MAX_PAGE_SIZE);
|
||||
const params = { days: SALES_WINDOW_DAYS, latestDate };
|
||||
const placeholders = list.map((sku, i) => {
|
||||
params[`sku${i}`] = sku;
|
||||
return `@sku${i}`;
|
||||
});
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT d.skuoid AS skuoid, d.shop_name AS shopName, d.ider AS operator,
|
||||
SUM(d.sales_amount) AS amt
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid IN (${placeholders.join(',')})
|
||||
GROUP BY d.skuoid, d.shop_name, d.ider`,
|
||||
params,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
const byChannel = new Map();
|
||||
const byOperator = new Map();
|
||||
rows.forEach((row) => {
|
||||
const sku = String(row.skuoid || '').trim();
|
||||
const amt = toNumberOrNull(row.amt) || 0;
|
||||
if (!sku) return;
|
||||
if (row.shopName) {
|
||||
const key = `${sku}||${row.shopName}`;
|
||||
const prev = byChannel.get(key) || 0;
|
||||
byChannel.set(key, prev + amt);
|
||||
}
|
||||
if (row.operator) {
|
||||
const key = `${sku}||${row.operator}`;
|
||||
const prev = byOperator.get(key) || 0;
|
||||
byOperator.set(key, prev + amt);
|
||||
}
|
||||
});
|
||||
|
||||
const pickTop = (map, targetMap) => {
|
||||
map.forEach((amt, key) => {
|
||||
const [sku, name] = key.split('||');
|
||||
const prev = targetMap.get(sku);
|
||||
if (!prev || amt > prev.amt) targetMap.set(sku, { name, amt });
|
||||
});
|
||||
};
|
||||
const topChannel = new Map();
|
||||
const topOperator = new Map();
|
||||
pickTop(byChannel, topChannel);
|
||||
pickTop(byOperator, topOperator);
|
||||
|
||||
list.forEach((sku) => {
|
||||
result.set(sku, {
|
||||
mainSalesChannel: topChannel.get(sku)?.name || null,
|
||||
mainSalesOperator: topOperator.get(sku)?.name || null,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载主渠道/主运营失败,相关列将留空:', error.message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 行下钻:该 SKU 归属的运营人员列表 + 各自日销(近 7 天日均发货件数)
|
||||
async function getProductOperators(skuoid) {
|
||||
const sku = String(skuoid || '').trim();
|
||||
if (!sku) return [];
|
||||
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return [];
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT d.ider AS operator,
|
||||
SUM(d.goods_count) AS qty7d,
|
||||
SUM(d.sales_amount) AS amt7d,
|
||||
MAX(d.shop_name) AS sampleShop
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid = @sku
|
||||
AND d.ider IS NOT NULL AND d.ider <> N''
|
||||
GROUP BY d.ider
|
||||
ORDER BY SUM(d.goods_count) DESC`,
|
||||
{ days: SALES_WINDOW_DAYS, latestDate, sku },
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
// 每位运营的主渠道
|
||||
const channelRows = await cloudDatabase.query(
|
||||
`SELECT d.ider AS operator, d.shop_name AS shopName, SUM(d.sales_amount) AS amt
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid = @sku AND d.ider IS NOT NULL AND d.ider <> N''
|
||||
GROUP BY d.ider, d.shop_name`,
|
||||
{ days: SALES_WINDOW_DAYS, latestDate, sku },
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
const topChannel = new Map();
|
||||
channelRows.forEach((row) => {
|
||||
const amt = toNumberOrNull(row.amt) || 0;
|
||||
const prev = topChannel.get(row.operator);
|
||||
if (!prev || amt > prev.amt) topChannel.set(row.operator, { name: row.shopName, amt });
|
||||
});
|
||||
|
||||
return rows.map((row) => {
|
||||
const qty7d = toNumberOrNull(row.qty7d) || 0;
|
||||
return {
|
||||
operator: row.operator,
|
||||
dailySales: Number((qty7d / SALES_WINDOW_DAYS).toFixed(2)),
|
||||
salesQty7d: qty7d,
|
||||
salesAmount7d: toNumberOrNull(row.amt7d),
|
||||
mainChannel: topChannel.get(row.operator)?.name || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 筛选选项 ====================
|
||||
|
||||
async function queryFilterOptions(latestDate) {
|
||||
const [dimensionRows, statusRows] = await Promise.all([
|
||||
cloudDatabase.query(
|
||||
`SELECT DISTINCT TOP 300 m.category AS category, m.supplier AS supplier, m.warehouse AS warehouse,
|
||||
m.purchase AS purchaser
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
WHERE m.date = @latestDate`,
|
||||
{ latestDate }
|
||||
),
|
||||
cloudDatabase.query(
|
||||
`SELECT m.sku_idStatus AS status, COUNT(*) AS c
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
WHERE m.date = @latestDate
|
||||
GROUP BY m.sku_idStatus`,
|
||||
{ latestDate }
|
||||
),
|
||||
]);
|
||||
|
||||
const uniq = (key) => [...new Set(dimensionRows.map((r) => r[key]).filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b), 'zh-CN'));
|
||||
|
||||
return {
|
||||
categories: uniq('category'),
|
||||
suppliers: uniq('supplier'),
|
||||
warehouses: uniq('warehouse'),
|
||||
purchasers: uniq('purchaser'),
|
||||
saleStatuses: SALE_STATUS_OPTIONS.map((status) => ({
|
||||
status,
|
||||
count: Number(statusRows.find((r) => r.status === status)?.c || 0),
|
||||
})),
|
||||
latestDate,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 数据集缓存 ====================
|
||||
//
|
||||
// 实测:mssql 驱动逐行开销约 0.4ms/行,取全量 5.6 万行要 20~30 秒,不能每次请求都跑。
|
||||
// 而「公司标签 / 部门标签 / 部门负责人」依赖系统库配置,无法下推到 SQL,必须拿到全量行再算。
|
||||
// 因此:按统计日缓存一份「已完成派生」的全量数据集,后续请求只在内存里过滤 + 排序 + 分页。
|
||||
// 缓存失效时机:① 统计日变化(日更)② 保存部门标签/负责人配置 ③ 前端带 refresh=1。
|
||||
|
||||
const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
let datasetCache = null;
|
||||
let datasetBuilding = null;
|
||||
|
||||
const dateKeyOf = (value) => (value ? new Date(value).toISOString().slice(0, 10) : '');
|
||||
|
||||
async function buildDataset(latestDate) {
|
||||
const key = dateKeyOf(latestDate);
|
||||
const [baseResult, departmentRules, departmentOwners, companyTagConfig, companyOwnerIndex, salesAggIndex] = await Promise.all([
|
||||
queryAllReportRows(latestDate),
|
||||
loadDepartmentTagRules(),
|
||||
loadDepartmentSkuOwners(),
|
||||
loadCompanyTagResolver(),
|
||||
loadCompanyOwnerIndex(),
|
||||
loadSalesAggregatesBySku(),
|
||||
]);
|
||||
|
||||
const rows = baseResult.rows.map((row) => {
|
||||
const salesAgg = salesAggIndex.get(String(row.skuCode || row.skuId).trim()) || null;
|
||||
|
||||
// 业绩口径 = 近 30 天销售额(对齐 SABC 阈值量级),近 7 天销售额单独留一列展示
|
||||
const performance30d = salesAgg?.salesAmount30d ?? null;
|
||||
const performance7d = salesAgg?.salesAmount7d ?? null;
|
||||
const departmentSalesQty7d = salesAgg?.salesQty7d ?? null;
|
||||
|
||||
const companyTag = resolveCompanyTag(companyTagConfig, row.category, performance30d, row.companySales7d);
|
||||
const departmentTag = matchDepartmentTag(departmentRules, departmentSalesQty7d);
|
||||
const department = salesAgg?.department || null;
|
||||
const owner = resolveDepartmentOwner(departmentOwners, { ...row, department });
|
||||
|
||||
return {
|
||||
...row,
|
||||
companyOwner: companyOwnerIndex.get(String(row.spu || '').trim()) || null,
|
||||
department,
|
||||
// 业绩(销售额)——30 天用于 SABC 判定,7 天用于展示
|
||||
performance7d,
|
||||
performance30d,
|
||||
// 部门近7日日销(件数 / 7)
|
||||
departmentSales7d: departmentSalesQty7d === null
|
||||
? null
|
||||
: Number((departmentSalesQty7d / SALES_WINDOW_DAYS).toFixed(2)),
|
||||
departmentSalesQty7d,
|
||||
// 建议备货:宽表 suggestStock 为有符号净需求,负数代表库存过剩,展示时取 0
|
||||
suggestedReplenish: row.suggestedReplenish === null ? null : Math.max(0, row.suggestedReplenish),
|
||||
companyTag,
|
||||
departmentTag,
|
||||
departmentOwner: owner?.ownerName || null,
|
||||
mainSalesChannel: null,
|
||||
mainSalesOperator: null,
|
||||
};
|
||||
});
|
||||
|
||||
return { key, builtAt: Date.now(), rows };
|
||||
}
|
||||
|
||||
async function getDataset(force = false) {
|
||||
const latestDate = await getLatestReportDate();
|
||||
const key = dateKeyOf(latestDate);
|
||||
|
||||
const fresh = datasetCache && datasetCache.key === key
|
||||
&& (Date.now() - datasetCache.builtAt) < DATASET_CACHE_TTL_MS;
|
||||
if (!force && fresh) return { dataset: datasetCache, latestDate };
|
||||
|
||||
// 并发去重:同一统计日的构建只跑一次
|
||||
if (datasetBuilding && datasetBuilding.key === key) {
|
||||
return { dataset: await datasetBuilding.promise, latestDate };
|
||||
}
|
||||
|
||||
const promise = buildDataset(latestDate)
|
||||
.then((dataset) => {
|
||||
datasetCache = dataset;
|
||||
console.log(`[ProductMonitoring] 数据集构建完成:${key},共 ${dataset.rows.length} 行`);
|
||||
return dataset;
|
||||
})
|
||||
.finally(() => { datasetBuilding = null; });
|
||||
|
||||
datasetBuilding = { key, promise };
|
||||
return { dataset: await promise, latestDate };
|
||||
}
|
||||
|
||||
function invalidateDataset() {
|
||||
datasetCache = null;
|
||||
}
|
||||
|
||||
// 启动预热:不阻塞应用启动,构建失败只记日志(首次请求会重试)
|
||||
async function warmProductMonitoringDataset() {
|
||||
try {
|
||||
await getDataset();
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 数据集预热失败,将在首次请求时重试:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 基础字段过滤(原 SQL WHERE 的等价物,改为内存匹配)
|
||||
function applyBaseFilters(rows = [], query = {}) {
|
||||
const like = (value, keyword) => String(value ?? '').toLowerCase()
|
||||
.includes(String(keyword ?? '').trim().toLowerCase());
|
||||
|
||||
const keywordFields = [
|
||||
['category', 'category'],
|
||||
['spu', 'spu'],
|
||||
['sku', 'skuId'],
|
||||
['productName', 'productName'],
|
||||
['supplier', 'supplier'],
|
||||
['warehouse', 'warehouse'],
|
||||
['purchaser', 'purchaser'],
|
||||
].map(([param, field]) => [field, String(query[param] ?? '').trim()])
|
||||
.filter(([, keyword]) => keyword);
|
||||
|
||||
const saleStatus = String(query.saleStatus ?? '').trim();
|
||||
const includeAll = String(query.includeAll ?? '') === '1';
|
||||
const status = saleStatus || (includeAll ? '' : '销售');
|
||||
|
||||
if (!keywordFields.length && !status) return rows;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (status && String(row.saleStatus || '') !== status) return false;
|
||||
return keywordFields.every(([field, keyword]) => like(row[field], keyword));
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 主入口 ====================
|
||||
|
||||
async function getMonitoringList(query = {}) {
|
||||
const forceRefresh = String(query.refresh ?? '') === '1';
|
||||
const { dataset, latestDate } = await getDataset(forceRefresh);
|
||||
|
||||
// 1) 基础字段过滤 → 2) 派生字段过滤 → 3) 排序 → 4) 分页
|
||||
const filtered = applyDerivedFilters(applyBaseFilters(dataset.rows, query), query);
|
||||
const sorted = sortRows(filtered, query);
|
||||
|
||||
const total = sorted.length;
|
||||
const page = Math.max(1, Number(query.page) || 1);
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE));
|
||||
const pageRows = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
// 主渠道 / 主运营只对本页 SKU 查询,控制代价
|
||||
const mainChannelIndex = await loadMainChannelAndOperator(pageRows.map((row) => row.skuCode || row.skuId));
|
||||
const rows = pageRows.map((row) => {
|
||||
const main = mainChannelIndex.get(String(row.skuCode || row.skuId).trim()) || {};
|
||||
return {
|
||||
...row,
|
||||
mainSalesChannel: main.mainSalesChannel || null,
|
||||
mainSalesOperator: main.mainSalesOperator || null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
latestDate,
|
||||
cachedAt: dataset.builtAt,
|
||||
summary: buildSummary(sorted),
|
||||
};
|
||||
}
|
||||
|
||||
// 派生字段过滤:多选用逗号分隔(companyTag=S,A),文本用包含匹配
|
||||
function applyDerivedFilters(rows = [], query = {}) {
|
||||
const parseList = (value) => String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const companyTags = parseList(query.companyTag);
|
||||
const departmentTags = parseList(query.departmentTag);
|
||||
const departmentKeyword = String(query.department ?? '').trim();
|
||||
const departmentOwnerKeyword = String(query.departmentOwner ?? '').trim().toLowerCase();
|
||||
const companyOwnerKeyword = String(query.companyOwner ?? '').trim().toLowerCase();
|
||||
|
||||
if (!companyTags.length && !departmentTags.length && !departmentKeyword
|
||||
&& !departmentOwnerKeyword && !companyOwnerKeyword) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (companyTags.length && !companyTags.includes(String(row.companyTag || ''))) return false;
|
||||
if (departmentTags.length && !departmentTags.includes(String(row.departmentTag || ''))) return false;
|
||||
if (departmentKeyword && String(row.department || '') !== departmentKeyword) return false;
|
||||
if (departmentOwnerKeyword && !String(row.departmentOwner || '').toLowerCase().includes(departmentOwnerKeyword)) return false;
|
||||
if (companyOwnerKeyword && !String(row.companyOwner || '').toLowerCase().includes(companyOwnerKeyword)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortRows(rows = [], query = {}) {
|
||||
const field = DERIVED_SORT_FIELDS.has(query.sortField) ? query.sortField : 'companySales7d';
|
||||
const desc = String(query.sortOrder || '').toUpperCase() !== 'ASC';
|
||||
const direction = desc ? -1 : 1;
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = a[field];
|
||||
const bv = b[field];
|
||||
// 空值恒排末尾,避免 null 参与数值/字符串比较造成的抖动
|
||||
if (av === null || av === undefined || av === '') {
|
||||
return bv === null || bv === undefined || bv === '' ? String(a.skuId).localeCompare(String(b.skuId)) : 1;
|
||||
}
|
||||
if (bv === null || bv === undefined || bv === '') return -1;
|
||||
|
||||
const an = Number(av);
|
||||
const bn = Number(bv);
|
||||
const bothNumeric = Number.isFinite(an) && Number.isFinite(bn);
|
||||
const delta = bothNumeric
|
||||
? an - bn
|
||||
: String(av).localeCompare(String(bv), 'zh-CN');
|
||||
|
||||
return (delta || String(a.skuId).localeCompare(String(b.skuId))) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
// 公司 SABC:复用「商品标签配置」的阈值,按 (品类, 业绩, 日销) 判定规模标签
|
||||
async function loadCompanyTagResolver() {
|
||||
try {
|
||||
const config = await productTagConfigService.getProductTagConfig();
|
||||
const thresholds = config?.scaleThresholds || [];
|
||||
const byCategory = new Map();
|
||||
thresholds.forEach((t) => {
|
||||
if (t.enabled === false) return;
|
||||
const key = String(t.category || '');
|
||||
if (!byCategory.has(key)) byCategory.set(key, []);
|
||||
byCategory.get(key).push(t);
|
||||
});
|
||||
return { byCategory, mappings: config?.sabcMappings || [] };
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载公司标签配置失败,标签列将留空:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 判定:业绩与日销同时命中(左闭右开),按 priority 升序命中即停
|
||||
function resolveCompanyTag(config, category, performance, sales) {
|
||||
if (!config) return null;
|
||||
const ofCategory = config.byCategory.get(String(category || ''));
|
||||
if (!ofCategory || !ofCategory.length) return null;
|
||||
// 业绩缺失时按 0 处理(区间在业务上必然从 0 起),避免整列留空
|
||||
const perf = performance === null || performance === undefined ? 0 : Number(performance);
|
||||
const sale = sales === null || sales === undefined ? 0 : Number(sales);
|
||||
const scaleTag = productTagConfigService.matchScaleTagOfCategory(ofCategory, perf, sale);
|
||||
if (!scaleTag || scaleTag === '滞销款') return 'D';
|
||||
// 利润等级当前无上游数据源,同规模标签下取最优档作为展示值
|
||||
const hit = config.mappings.find((m) => m.scaleTag === scaleTag && m.enabled !== false);
|
||||
return hit?.sabcTag || null;
|
||||
}
|
||||
|
||||
// 部门负责人:优先「部门 + SPU」精确配置 → 「部门 + SPU 为空」的部门级默认配置
|
||||
function resolveDepartmentOwner(owners, row) {
|
||||
if (!owners.length) return null;
|
||||
const deptName = String(row.department || '').trim();
|
||||
const spu = String(row.spu || '').trim();
|
||||
if (!deptName) return null;
|
||||
|
||||
const ofDept = owners.filter((o) => o.ownerType === 'sku_owner' && o.departmentName === deptName);
|
||||
if (!ofDept.length) return null;
|
||||
|
||||
const bySpu = spu ? ofDept.find((o) => String(o.spu || '').trim() === spu) : null;
|
||||
if (bySpu) return bySpu;
|
||||
return ofDept.find((o) => !o.spu) || null;
|
||||
}
|
||||
|
||||
async function getMonitoringFilters() {
|
||||
const latestDate = await getLatestReportDate();
|
||||
const [options, departmentRules] = await Promise.all([
|
||||
queryFilterOptions(latestDate),
|
||||
loadDepartmentTagRules(),
|
||||
]);
|
||||
return {
|
||||
...options,
|
||||
companyTags: ['S', 'A', 'B', 'C', 'D'],
|
||||
departmentTags: DEPARTMENT_TAG_LEVELS,
|
||||
departmentTagRules: departmentRules,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 部门标签配置(系统设置用) ====================
|
||||
|
||||
async function getDepartmentTagConfig() {
|
||||
const [rules, owners, departments, users] = await Promise.all([
|
||||
loadDepartmentTagRules(),
|
||||
loadDepartmentSkuOwners(),
|
||||
database.local.query('SELECT id, name, code, status FROM departments WHERE status = ? ORDER BY sort ASC, id ASC', ['active']),
|
||||
database.local.query('SELECT id, name, username, role FROM users ORDER BY id ASC LIMIT 1000'),
|
||||
]);
|
||||
|
||||
return {
|
||||
rules: DEPARTMENT_TAG_LEVELS.map((level) => {
|
||||
const hit = rules.find((r) => r.tagLevel === level);
|
||||
return {
|
||||
tagLevel: level,
|
||||
metricKey: hit?.metricKey || 'sales_qty_7d',
|
||||
thresholdMin: hit?.thresholdMin ?? null,
|
||||
thresholdMax: hit?.thresholdMax ?? null,
|
||||
priority: hit?.priority ?? (DEPARTMENT_TAG_LEVELS.indexOf(level) + 1),
|
||||
enabled: Boolean(hit),
|
||||
};
|
||||
}),
|
||||
metricKeys: [...DEPARTMENT_METRIC_KEYS],
|
||||
owners,
|
||||
departments: departments.map((d) => ({ id: d.id, name: d.name, code: d.code })),
|
||||
users: users.map((u) => ({ id: u.id, name: u.name, username: u.username, role: u.role })),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveDepartmentTagConfig(payload = {}) {
|
||||
// 标签/负责人配置变了,已缓存的数据集里的派生字段立即失效
|
||||
invalidateDataset();
|
||||
|
||||
const rules = Array.isArray(payload.rules) ? payload.rules : null;
|
||||
const owners = Array.isArray(payload.owners) ? payload.owners : null;
|
||||
|
||||
if (rules) {
|
||||
validateDepartmentRules(rules);
|
||||
await database.local.query('DELETE FROM product_tag_rules WHERE tag_scope = ?', [DEPARTMENT_TAG_SCOPE]);
|
||||
for (const rule of rules) {
|
||||
if (rule.enabled === false) continue;
|
||||
await database.local.query(
|
||||
`INSERT INTO product_tag_rules
|
||||
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
metric_key = VALUES(metric_key),
|
||||
threshold_min = VALUES(threshold_min),
|
||||
threshold_max = VALUES(threshold_max),
|
||||
priority = VALUES(priority),
|
||||
enabled = 1`,
|
||||
[
|
||||
DEPARTMENT_TAG_SCOPE,
|
||||
String(rule.tagLevel),
|
||||
DEPARTMENT_METRIC_KEYS.includes(rule.metricKey) ? rule.metricKey : 'sales_qty_7d',
|
||||
toNumberOrNull(rule.thresholdMin),
|
||||
toNumberOrNull(rule.thresholdMax),
|
||||
Number(rule.priority) || 0,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (owners) {
|
||||
validateDepartmentOwners(owners);
|
||||
await database.local.query('DELETE FROM department_sku_owners');
|
||||
for (const owner of owners) {
|
||||
await database.local.query(
|
||||
`INSERT INTO department_sku_owners (department_id, owner_type, user_id, spu)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id)`,
|
||||
[
|
||||
Number(owner.departmentId),
|
||||
owner.ownerType === 'purchaser' ? 'purchaser' : 'sku_owner',
|
||||
Number(owner.userId),
|
||||
owner.spu ? String(owner.spu).trim() : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return getDepartmentTagConfig();
|
||||
}
|
||||
|
||||
function validateDepartmentRules(rules) {
|
||||
const seen = new Set();
|
||||
rules.forEach((rule, index) => {
|
||||
const line = index + 1;
|
||||
if (!DEPARTMENT_TAG_LEVELS.includes(String(rule.tagLevel))) {
|
||||
throw new Error(`第 ${line} 行:部门标签必须是 ${DEPARTMENT_TAG_LEVELS.join(' / ')}`);
|
||||
}
|
||||
const min = toNumberOrNull(rule.thresholdMin);
|
||||
const max = toNumberOrNull(rule.thresholdMax);
|
||||
if (min !== null && max !== null && min >= max) {
|
||||
throw new Error(`第 ${line} 行:${rule.tagLevel} 的起点(${min}) 必须小于终点(${max})`);
|
||||
}
|
||||
if (seen.has(rule.tagLevel)) {
|
||||
throw new Error(`第 ${line} 行:部门标签「${rule.tagLevel}」重复`);
|
||||
}
|
||||
seen.add(rule.tagLevel);
|
||||
});
|
||||
}
|
||||
|
||||
function validateDepartmentOwners(owners) {
|
||||
const seen = new Set();
|
||||
owners.forEach((owner, index) => {
|
||||
const line = index + 1;
|
||||
if (!owner.departmentId) throw new Error(`第 ${line} 行:必须选择部门`);
|
||||
if (!owner.userId) throw new Error(`第 ${line} 行:必须选择负责人`);
|
||||
const key = `${owner.departmentId}||${owner.ownerType}||${owner.spu || ''}||${owner.userId}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:部门/负责人/SPU 组合重复`);
|
||||
seen.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureProductMonitoringSchema,
|
||||
getMonitoringList,
|
||||
getMonitoringFilters,
|
||||
getDepartmentTagConfig,
|
||||
saveDepartmentTagConfig,
|
||||
warmProductMonitoringDataset,
|
||||
invalidateDataset,
|
||||
getProductOperators,
|
||||
matchDepartmentTag,
|
||||
DEPARTMENT_TAG_LEVELS,
|
||||
DEPARTMENT_METRIC_KEYS,
|
||||
SALE_STATUS_OPTIONS,
|
||||
};
|
||||
@ -1,521 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 商品标签配置:规模标签阈值、利润标签阈值、SABC 映射
|
||||
//
|
||||
// 数据来源:《公司SABC标签配置.xlsx》
|
||||
// - 区块① 分类 × 业绩/日销区间 → 规模标签(148 行)
|
||||
// - 区块② 规模标签 × 利润标签 → SABC(25 行)
|
||||
//
|
||||
// 判定口径(2026-09-11 评审确认):
|
||||
// 1. SABC 保留 5 档,含 D
|
||||
// 2. 利润标签是上游传入的输入值,直接查表;营业利润率仅作展示说明,不参与计算
|
||||
// 3. 区间一律「左闭右开」[起点, 终点);规模标签按 priority 升序命中即停
|
||||
// 4. 业绩与日销必须同时命中
|
||||
// 5. 阈值导入时统一取整
|
||||
// 6. 品类为 '-' 的行跳过
|
||||
// 7. 暂不实现标签自动重算
|
||||
|
||||
const database = require('../config/database');
|
||||
|
||||
const SCALE_TAGS = Object.freeze(['爆款', '热卖款', '动销款', '低动销款']);
|
||||
const FALLBACK_SCALE_TAG = '滞销款';
|
||||
const SCALE_TAG_ORDER = Object.freeze([...SCALE_TAGS, FALLBACK_SCALE_TAG]);
|
||||
const PROFIT_LEVELS = Object.freeze(['一级', '二级', '三级', '四级', '五级']);
|
||||
const SABC_TAGS = Object.freeze(['S', 'A', 'B', 'C', 'D']);
|
||||
|
||||
// 区块① 中作为脏数据占位、导入时跳过的品类值
|
||||
const IGNORED_CATEGORIES = Object.freeze(['-', '']);
|
||||
|
||||
// 有意不参与评级的品类(2026-09-11 决策 B)。
|
||||
// 与「未配置」语义不同:这里是明确决定不评级,不是遗漏,前端要能区分展示。
|
||||
// 存在 system_config 里,无需为它单独建表。
|
||||
const EXCLUDED_CATEGORIES_CONFIG_KEY = 'product_tag_excluded_categories';
|
||||
const DEFAULT_EXCLUDED_CATEGORIES = Object.freeze(['其他 - 配件']);
|
||||
|
||||
// 未评级原因
|
||||
const RATING_SKIP_EXCLUDED = 'excluded';
|
||||
const RATING_SKIP_UNCONFIGURED = 'unconfigured';
|
||||
|
||||
async function ensureTable(tableName, createSql) {
|
||||
await database.local.query(createSql);
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`Table ${tableName} create failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProductTagConfigSchema() {
|
||||
await ensureTable(
|
||||
'product_scale_thresholds',
|
||||
`CREATE TABLE IF NOT EXISTS product_scale_thresholds (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌',
|
||||
display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)',
|
||||
scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款',
|
||||
performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)',
|
||||
performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)',
|
||||
sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)',
|
||||
sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_category_tag (category, scale_tag),
|
||||
KEY idx_category_priority (category, enabled, priority),
|
||||
KEY idx_display_name (display_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'product_profit_levels',
|
||||
`CREATE TABLE IF NOT EXISTS product_profit_levels (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level_code VARCHAR(16) NOT NULL COMMENT '一级~五级',
|
||||
profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)',
|
||||
description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_level_code (level_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'product_sabc_mappings',
|
||||
`CREATE TABLE IF NOT EXISTS product_sabc_mappings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scale_tag VARCHAR(32) NOT NULL,
|
||||
profit_level VARCHAR(16) NOT NULL,
|
||||
sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scale_profit (scale_tag, profit_level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射'`
|
||||
);
|
||||
}
|
||||
|
||||
function toNullableNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
// 决策 5:只对「计算溢出型」脏数据取整,合法的小数(如日销起点 0.5)必须保留。
|
||||
// 判据:小数位数 > 2 位(如 7916.34311148132、2063.13142776357)→ 取整;
|
||||
// 0.5 / 1.25 这类 ≤2 位小数的正常阈值原样保留。
|
||||
function normalizeThreshold(value) {
|
||||
const num = toNullableNumber(value);
|
||||
if (num === null) return null;
|
||||
const decimals = String(num).split('.')[1];
|
||||
if (decimals && decimals.length > 2) return Math.round(num);
|
||||
return num;
|
||||
}
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value === null || value === undefined ? '' : value).trim();
|
||||
}
|
||||
|
||||
function assertOneOf(value, allowed, label) {
|
||||
if (!allowed.includes(value)) {
|
||||
throw new Error(`${label} 取值非法:${value},允许值 ${allowed.join(' / ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 读取 ====================
|
||||
|
||||
async function loadScaleThresholds() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled
|
||||
FROM product_scale_thresholds
|
||||
ORDER BY category ASC, priority ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
displayName: row.display_name || row.category,
|
||||
scaleTag: row.scale_tag,
|
||||
performanceMin: toNullableNumber(row.performance_min),
|
||||
performanceMax: toNullableNumber(row.performance_max),
|
||||
salesMin: toNullableNumber(row.sales_min),
|
||||
salesMax: toNullableNumber(row.sales_max),
|
||||
priority: toNumber(row.priority),
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadProfitLevels() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, level_code, profit_rate_max, description, sort, enabled
|
||||
FROM product_profit_levels
|
||||
ORDER BY sort ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
levelCode: row.level_code,
|
||||
profitRateMax: toNullableNumber(row.profit_rate_max),
|
||||
description: row.description || '',
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadSabcMappings() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, scale_tag, profit_level, sabc_tag, sort, enabled
|
||||
FROM product_sabc_mappings
|
||||
ORDER BY sort ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
scaleTag: row.scale_tag,
|
||||
profitLevel: row.profit_level,
|
||||
sabcTag: row.sabc_tag,
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function getProductTagConfig() {
|
||||
const [scaleThresholds, profitLevels, sabcMappings, excludedCategories] = await Promise.all([
|
||||
loadScaleThresholds(),
|
||||
loadProfitLevels(),
|
||||
loadSabcMappings(),
|
||||
loadExcludedCategories(),
|
||||
]);
|
||||
return {
|
||||
scaleThresholds,
|
||||
profitLevels,
|
||||
sabcMappings,
|
||||
excludedCategories,
|
||||
meta: {
|
||||
scaleTags: SCALE_TAG_ORDER,
|
||||
profitLevels: PROFIT_LEVELS,
|
||||
sabcTags: SABC_TAGS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 排除品类(不参与评级) ====================
|
||||
|
||||
async function loadExcludedCategories() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
// 没有配置记录时用默认值(配件不参与评级,决策 B)
|
||||
if (!rows.length || !rows[0].config_value) return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
const parsed = JSON.parse(rows[0].config_value);
|
||||
if (!Array.isArray(parsed)) return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
return parsed.map((item) => normalizeText(item)).filter(Boolean);
|
||||
} catch (_error) {
|
||||
return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveExcludedCategories(list) {
|
||||
const normalized = [...new Set((list || []).map((item) => normalizeText(item)).filter(Boolean))];
|
||||
const payload = JSON.stringify(normalized);
|
||||
const existing = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
if (existing.length) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[payload, 'json', EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.query(
|
||||
'INSERT INTO system_config (config_key, config_value, config_type) VALUES (?, ?, ?)',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY, payload, 'json']
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ==================== 校验 ====================
|
||||
|
||||
function validateScaleThresholds(rows) {
|
||||
const seen = new Set();
|
||||
rows.forEach((row, index) => {
|
||||
const line = index + 1;
|
||||
const category = normalizeText(row.category);
|
||||
if (!category) throw new Error(`第 ${line} 行:品类(jst_sku.category 完整值)不能为空`);
|
||||
if (IGNORED_CATEGORIES.includes(category)) throw new Error(`第 ${line} 行:品类 "${category}" 已标记为忽略,不应提交`);
|
||||
assertOneOf(normalizeText(row.scaleTag), SCALE_TAGS, `第 ${line} 行 规模标签`);
|
||||
|
||||
const key = `${category}||${row.scaleTag}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:品类「${category}」的「${row.scaleTag}」重复`);
|
||||
seen.add(key);
|
||||
|
||||
const pMin = toNullableNumber(row.performanceMin);
|
||||
const pMax = toNullableNumber(row.performanceMax);
|
||||
const sMin = toNullableNumber(row.salesMin);
|
||||
const sMax = toNullableNumber(row.salesMax);
|
||||
|
||||
if (pMin !== null && pMax !== null && pMin >= pMax) {
|
||||
throw new Error(`第 ${line} 行:业绩起点(${pMin}) 必须小于 业绩终点(${pMax})`);
|
||||
}
|
||||
if (sMin !== null && sMax !== null && sMin >= sMax) {
|
||||
throw new Error(`第 ${line} 行:日销起点(${sMin}) 必须小于 日销终点(${sMax})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSabcMappings(rows) {
|
||||
const seen = new Set();
|
||||
rows.forEach((row, index) => {
|
||||
const line = index + 1;
|
||||
assertOneOf(normalizeText(row.scaleTag), SCALE_TAG_ORDER, `第 ${line} 行 规模标签`);
|
||||
assertOneOf(normalizeText(row.profitLevel), PROFIT_LEVELS, `第 ${line} 行 利润标签`);
|
||||
assertOneOf(normalizeText(row.sabcTag), SABC_TAGS, `第 ${line} 行 SABC`);
|
||||
|
||||
const key = `${row.scaleTag}||${row.profitLevel}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:规模标签「${row.scaleTag}」× 利润标签「${row.profitLevel}」重复`);
|
||||
seen.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 保存(整表替换) ====================
|
||||
|
||||
async function replaceScaleThresholds(rows) {
|
||||
validateScaleThresholds(rows);
|
||||
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||
if (!rows.length) return 0;
|
||||
|
||||
const values = [];
|
||||
const placeholders = rows.map((row) => {
|
||||
// priority 由文档顺序推导:爆款=1 热卖款=2 动销款=3 低动销款=4
|
||||
const priority = SCALE_TAGS.indexOf(normalizeText(row.scaleTag)) + 1;
|
||||
values.push(
|
||||
normalizeText(row.category),
|
||||
normalizeText(row.displayName) || normalizeText(row.category),
|
||||
normalizeText(row.scaleTag),
|
||||
normalizeThreshold(row.performanceMin),
|
||||
normalizeThreshold(row.performanceMax),
|
||||
normalizeThreshold(row.salesMin),
|
||||
normalizeThreshold(row.salesMax),
|
||||
priority,
|
||||
toNumber(row.sort, priority),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_scale_thresholds
|
||||
(category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function replaceProfitLevels(rows) {
|
||||
const list = rows && rows.length ? rows : PROFIT_LEVELS.map((levelCode) => ({ levelCode }));
|
||||
list.forEach((row, index) => {
|
||||
assertOneOf(normalizeText(row.levelCode), PROFIT_LEVELS, `第 ${index + 1} 行 利润标签`);
|
||||
});
|
||||
|
||||
const seen = new Set();
|
||||
list.forEach((row, index) => {
|
||||
const code = normalizeText(row.levelCode);
|
||||
if (seen.has(code)) throw new Error(`第 ${index + 1} 行:利润标签「${code}」重复`);
|
||||
seen.add(code);
|
||||
});
|
||||
|
||||
await database.local.query('DELETE FROM product_profit_levels');
|
||||
const values = [];
|
||||
const placeholders = list.map((row, index) => {
|
||||
values.push(
|
||||
normalizeText(row.levelCode),
|
||||
toNullableNumber(row.profitRateMax),
|
||||
normalizeText(row.description) || null,
|
||||
toNumber(row.sort, index + 1),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function replaceSabcMappings(rows) {
|
||||
validateSabcMappings(rows);
|
||||
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||
if (!rows.length) return 0;
|
||||
|
||||
const values = [];
|
||||
const placeholders = rows.map((row, index) => {
|
||||
values.push(
|
||||
normalizeText(row.scaleTag),
|
||||
normalizeText(row.profitLevel),
|
||||
normalizeText(row.sabcTag),
|
||||
toNumber(row.sort, index + 1),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// 一次性保存三个区块(前端「保存配置」按钮)
|
||||
async function saveProductTagConfig(payload = {}) {
|
||||
const result = {};
|
||||
if (Array.isArray(payload.scaleThresholds)) {
|
||||
result.scaleThresholdCount = await replaceScaleThresholds(payload.scaleThresholds);
|
||||
}
|
||||
if (Array.isArray(payload.profitLevels)) {
|
||||
result.profitLevelCount = await replaceProfitLevels(payload.profitLevels);
|
||||
}
|
||||
if (Array.isArray(payload.sabcMappings)) {
|
||||
result.sabcMappingCount = await replaceSabcMappings(payload.sabcMappings);
|
||||
}
|
||||
if (Array.isArray(payload.excludedCategories)) {
|
||||
result.excludedCategoryCount = (await saveExcludedCategories(payload.excludedCategories)).length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 运行时判定 ====================
|
||||
|
||||
// 决策 3 + 4:左闭右开,业绩与日销同时命中;决策 2:按 priority 升序命中即停
|
||||
//
|
||||
// 重要:必须区分两种「没命中」——
|
||||
// ① 该品类有配置阈值,但数值全部落在区间外 → 真·滞销款(兜底档)
|
||||
// ② 该品类**根本没有配置任何阈值** → 返回 null(未评级),不能算滞销款
|
||||
// 否则未配置的品类(如「其他 - 配件」47 万 SKU)会被整批误判成滞销款。
|
||||
function matchScaleTagOfCategory(thresholdsOfCategory, performance, sales) {
|
||||
const ordered = [...(thresholdsOfCategory || [])]
|
||||
.filter((row) => row.enabled !== false)
|
||||
.sort((a, b) => (a.priority - b.priority) || (a.id - b.id));
|
||||
|
||||
if (!ordered.length) return null;
|
||||
|
||||
for (const row of ordered) {
|
||||
const pIn = (row.performanceMin === null || performance >= row.performanceMin)
|
||||
&& (row.performanceMax === null || performance < row.performanceMax);
|
||||
const sIn = (row.salesMin === null || sales >= row.salesMin)
|
||||
&& (row.salesMax === null || sales < row.salesMax);
|
||||
if (pIn && sIn) return row.scaleTag;
|
||||
}
|
||||
return FALLBACK_SCALE_TAG;
|
||||
}
|
||||
|
||||
function buildScaleThresholdIndex(thresholds) {
|
||||
const index = new Map();
|
||||
(thresholds || []).forEach((row) => {
|
||||
const key = normalizeText(row.category);
|
||||
if (!index.has(key)) index.set(key, []);
|
||||
index.get(key).push(row);
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
function buildSabcIndex(mappings) {
|
||||
const index = new Map();
|
||||
(mappings || []).forEach((row) => {
|
||||
if (row.enabled === false) return;
|
||||
index.set(`${normalizeText(row.scaleTag)}||${normalizeText(row.profitLevel)}`, row.sabcTag);
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
// 批量解析:items = [{ category, performance, sales, profitLevel }]
|
||||
async function resolveProductTags(items) {
|
||||
const [thresholds, mappings, excluded] = await Promise.all([
|
||||
loadScaleThresholds(),
|
||||
loadSabcMappings(),
|
||||
loadExcludedCategories(),
|
||||
]);
|
||||
const thresholdIndex = buildScaleThresholdIndex(thresholds);
|
||||
const sabcIndex = buildSabcIndex(mappings);
|
||||
const excludedSet = new Set(excluded);
|
||||
|
||||
return (items || []).map((item) => {
|
||||
const category = normalizeText(item.category);
|
||||
|
||||
// 有意排除的品类:直接返回未评级,且标明原因,不与「未配置」混淆
|
||||
if (excludedSet.has(category)) {
|
||||
return {
|
||||
...item,
|
||||
scaleTag: null,
|
||||
sabcTag: null,
|
||||
rated: false,
|
||||
skippedReason: RATING_SKIP_EXCLUDED,
|
||||
};
|
||||
}
|
||||
|
||||
const rows = thresholdIndex.get(category);
|
||||
const scaleTag = matchScaleTagOfCategory(rows, toNullableNumber(item.performance), toNullableNumber(item.sales));
|
||||
const profitLevel = normalizeText(item.profitLevel);
|
||||
const sabcTag = (scaleTag && profitLevel)
|
||||
? (sabcIndex.get(`${scaleTag}||${profitLevel}`) || null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
scaleTag,
|
||||
sabcTag,
|
||||
rated: scaleTag !== null,
|
||||
// 该品类没有任何阈值配置 → 未评级(不能算滞销款)
|
||||
skippedReason: scaleTag === null ? RATING_SKIP_UNCONFIGURED : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCALE_TAGS,
|
||||
SCALE_TAG_ORDER,
|
||||
FALLBACK_SCALE_TAG,
|
||||
PROFIT_LEVELS,
|
||||
SABC_TAGS,
|
||||
IGNORED_CATEGORIES,
|
||||
DEFAULT_EXCLUDED_CATEGORIES,
|
||||
EXCLUDED_CATEGORIES_CONFIG_KEY,
|
||||
RATING_SKIP_EXCLUDED,
|
||||
RATING_SKIP_UNCONFIGURED,
|
||||
ensureProductTagConfigSchema,
|
||||
getProductTagConfig,
|
||||
saveProductTagConfig,
|
||||
loadExcludedCategories,
|
||||
saveExcludedCategories,
|
||||
resolveProductTags,
|
||||
matchScaleTagOfCategory,
|
||||
// 供单元测试或导入脚本复用
|
||||
normalizeThreshold,
|
||||
validateScaleThresholds,
|
||||
validateSabcMappings,
|
||||
};
|
||||
@ -2,12 +2,16 @@ const RETIRED_PAGE_KEYS = [
|
||||
'planning_upload', 'market_insight', 'agent', 'ai_skill_tags',
|
||||
'store_checkup', 'review_analysis', 'customer_service_checkup',
|
||||
'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video',
|
||||
'product_monitoring', 'ops_b_incentive', 'marketing_events', 'promotion_module', 'product_analysis',
|
||||
];
|
||||
const RETIRED_ENTRY_KEYS = new Set([
|
||||
'market-insight', 'agent', 'ai-skill-tags', 'platform-switch',
|
||||
'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup',
|
||||
'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video',
|
||||
'desktop-client-releases',
|
||||
'product-monitoring', 'ops-b-incentive',
|
||||
'marketing-management', 'marketing-events', 'promotion-module',
|
||||
'product-management', 'product-analysis',
|
||||
]);
|
||||
const RETIRED_CONFIG_KEYS = [
|
||||
'openclaw_chat_config', 'openclaw_chat_skill_tags',
|
||||
@ -15,6 +19,7 @@ const RETIRED_CONFIG_KEYS = [
|
||||
'cs_quality_ai_config', 'desktop_client_releases',
|
||||
'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url',
|
||||
'ai_buyer_show_vision_model', 'ai_buyer_show_review_model',
|
||||
'product_tag_excluded_categories',
|
||||
];
|
||||
const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database'];
|
||||
|
||||
@ -48,6 +53,15 @@ async function cleanupRetiredFeatureSettings(db) {
|
||||
await db.query(`DELETE FROM database_configs WHERE name IN (${RETIRED_DATABASE_CONFIG_NAMES.map(() => '?').join(',')})`, RETIRED_DATABASE_CONFIG_NAMES);
|
||||
await db.query('DROP TABLE IF EXISTS pricing_calculator_history');
|
||||
await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs');
|
||||
await db.query('DROP TABLE IF EXISTS ops_b_incentive_snapshots');
|
||||
await db.query('DROP TABLE IF EXISTS ops_b_incentive_periods');
|
||||
await db.query('DROP TABLE IF EXISTS product_analysis_user_prefs');
|
||||
await db.query('DROP TABLE IF EXISTS pdd_goods_first_seen');
|
||||
await db.query('DROP TABLE IF EXISTS department_sku_owners');
|
||||
await db.query('DROP TABLE IF EXISTS product_tag_rules');
|
||||
await db.query('DROP TABLE IF EXISTS product_sabc_mappings');
|
||||
await db.query('DROP TABLE IF EXISTS product_profit_levels');
|
||||
await db.query('DROP TABLE IF EXISTS product_scale_thresholds');
|
||||
const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']);
|
||||
if (rows.length) {
|
||||
const current = rows[0].config_value;
|
||||
|
||||
54
backend/test/dashboardRoutes.test.js
Normal file
54
backend/test/dashboardRoutes.test.js
Normal file
@ -0,0 +1,54 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const Module = require('node:module');
|
||||
const express = require('express');
|
||||
|
||||
function loadDashboardRouter() {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(id, parent, ...rest) {
|
||||
if (parent?.filename.endsWith('/api/dashboard.js') && id.endsWith('/jianSunDatabase')) {
|
||||
return {
|
||||
query: async (sql) => {
|
||||
if (sql.includes('SELECT DISTINCT shopName')) return [{ shopName: '测试店铺' }];
|
||||
if (sql.includes('FROM dbo.pdd_DataGoods g')) {
|
||||
return [{
|
||||
statDate: '2026-09-12', latestDate: '2026-09-12', payAmount: 100,
|
||||
buyerCount: 2, visitorCount: 10, goodsPv: 20, payGoodsQty: 3,
|
||||
payOrderCount: 2, favoriteCount: 1, impressionCount: 30,
|
||||
}];
|
||||
}
|
||||
if (sql.includes('FROM dbo.pdd_MallServiceScore')) return [{ shopCount: 1, cstmrServScore: 4.8 }];
|
||||
if (sql.includes('FROM dbo.pdd_ShopStars')) return [{ shopCount: 1, mallStarTomms: 5 }];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, id, parent, ...rest);
|
||||
};
|
||||
try {
|
||||
return require('../src/api/dashboard');
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
}
|
||||
|
||||
test('dashboard keeps its read-only data endpoints after product analysis is retired', async () => {
|
||||
const app = express();
|
||||
app.use('/api/dashboard', loadDashboardRouter());
|
||||
const server = await new Promise((resolve) => {
|
||||
const listener = app.listen(0, '127.0.0.1', () => resolve(listener));
|
||||
});
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const meta = await (await fetch(`${base}/api/dashboard/meta`)).json();
|
||||
assert.deepEqual(meta.data.shops, ['测试店铺']);
|
||||
|
||||
const summary = await (await fetch(`${base}/api/dashboard/summary?period=day`)).json();
|
||||
assert.equal(summary.success, true);
|
||||
assert.equal(summary.data.latestDate, '2026-09-12');
|
||||
assert.equal(summary.data.metrics.find((item) => item.key === 'payAmount').value, 100);
|
||||
assert.equal((await fetch(`${base}/api/product-analysis/products`)).status, 404);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@ -92,11 +92,9 @@ test('desktop capabilities follow resolved page permissions', () => {
|
||||
shopManagement: false,
|
||||
execution: false,
|
||||
logs: true,
|
||||
marketingEvents: false,
|
||||
promotion: false,
|
||||
productTest: false,
|
||||
});
|
||||
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true);
|
||||
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).productTest, true);
|
||||
});
|
||||
|
||||
test('SSO ticket is consumed exactly once and produces a normal web token', async () => {
|
||||
|
||||
@ -12,6 +12,10 @@ test('retired sidebar cleanup is recursive and idempotent', () => {
|
||||
{ key: 'agent' },
|
||||
{ key: 'store-management', children: [{ key: 'store-checkup' }] },
|
||||
{ key: 'operation-tools', children: [{ key: 'pricing-calculator' }] },
|
||||
{ key: 'marketing-management', children: [{ key: 'marketing-events' }] },
|
||||
{ key: 'product-management', children: [{ key: 'product-analysis' }] },
|
||||
{ key: 'product-monitoring' },
|
||||
{ key: 'ops-b-incentive' },
|
||||
{ key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'desktop-client-releases' }, { key: 'settings', label: '管理设置' }] },
|
||||
] });
|
||||
const clean = pruneRetiredSidebarConfig(saved);
|
||||
@ -20,6 +24,7 @@ test('retired sidebar cleanup is recursive and idempotent', () => {
|
||||
assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid');
|
||||
assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('cs_quality_ai_config'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('product_tag_excluded_categories'), true);
|
||||
assert.equal(isRetiredDatabaseConfigName('ai_buyer_show_database'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('site_name'), false);
|
||||
});
|
||||
@ -28,12 +33,12 @@ test('upgrade removes only retired settings and permission rows', async () => {
|
||||
const calls = [];
|
||||
await cleanupRetiredFeatureSettings({ query: async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : [];
|
||||
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"},{"key":"settings"}]}' }] : [];
|
||||
} });
|
||||
const deletes = calls.filter((call) => call.sql.startsWith('DELETE'));
|
||||
assert.equal(deletes.length, 5);
|
||||
assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0));
|
||||
assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 2);
|
||||
assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 11);
|
||||
const update = calls.find((call) => call.sql.startsWith('UPDATE'));
|
||||
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]);
|
||||
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'settings' }]);
|
||||
});
|
||||
|
||||
@ -3,14 +3,11 @@ const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
const express = require('express');
|
||||
|
||||
test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => {
|
||||
test('retired analysis endpoints return 404 while retained selection endpoint works', async () => {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function (id, parent, ...rest) {
|
||||
if (parent?.filename.endsWith('/api/planningUpload.js')) {
|
||||
if (id.endsWith('/pageAccess')) return { getResolvedUserWithPageAccess: async () => ({ role: 'admin' }) };
|
||||
if (id.endsWith('/opsBIncentiveBoardService')) return {
|
||||
getOpsBIncentiveBoard: async () => ({ rows: [] }), getOpsBIncentiveBatches: async () => [], getOpsBIncentiveDaily: async () => [],
|
||||
};
|
||||
if (id.endsWith('/alibaba1688ProductService')) return { getAlibaba1688Product: async (id) => ({ id }) };
|
||||
if (id.endsWith('/alibaba1688ProductEncodingRegistry')) return { attachEncodingDetail: async (value) => value };
|
||||
}
|
||||
@ -24,10 +21,10 @@ test('retired market endpoints return 404 while retained incentive and calculato
|
||||
const server = await new Promise((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}/api/planning-upload`;
|
||||
for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans']) {
|
||||
for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans', '/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily']) {
|
||||
assert.equal((await fetch(base + path)).status, 404, path);
|
||||
}
|
||||
for (const path of ['/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily', '/selection-fission/1688-products/123']) {
|
||||
for (const path of ['/selection-fission/1688-products/123']) {
|
||||
const response = await fetch(base + path);
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal((await response.json()).success, true);
|
||||
|
||||
@ -143,18 +143,6 @@ export const testSelectionImageGenerationSettings = () => {
|
||||
|
||||
|
||||
|
||||
// 商品标签配置(规模标签 / 利润标签 / SABC 映射)
|
||||
export const getProductTagConfig = () => {
|
||||
return request.get('/api/admin/product-tag-config');
|
||||
};
|
||||
|
||||
export const updateProductTagConfig = (data) => {
|
||||
return request.put('/api/admin/product-tag-config', data);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
export const getAuthConfig = () => {
|
||||
return request.get('/api/admin/auth-config');
|
||||
};
|
||||
|
||||
16
frontend/src/api/dashboard.js
Normal file
16
frontend/src/api/dashboard.js
Normal file
@ -0,0 +1,16 @@
|
||||
import request from './request';
|
||||
|
||||
let dashboardMetaInFlight = null;
|
||||
|
||||
export const getDashboardMeta = () => {
|
||||
if (!dashboardMetaInFlight) {
|
||||
dashboardMetaInFlight = request.get('/api/dashboard/meta').finally(() => {
|
||||
dashboardMetaInFlight = null;
|
||||
});
|
||||
}
|
||||
return dashboardMetaInFlight;
|
||||
};
|
||||
|
||||
export const getDashboardSummary = (params, config = {}) => (
|
||||
request.get('/api/dashboard/summary', { ...config, params })
|
||||
);
|
||||
@ -1,17 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
export const getMarketingEventsList = (params) => (
|
||||
request.get('/api/marketing-events/list', { params })
|
||||
);
|
||||
|
||||
export const getMarketingEventsLatestDate = () => (
|
||||
request.get('/api/marketing-events/latest-date')
|
||||
);
|
||||
|
||||
export const getMarketingEventsSummary = (params) => (
|
||||
request.get('/api/marketing-events/summary', { params })
|
||||
);
|
||||
|
||||
export const getMarketingEventsStatusCounts = () => (
|
||||
request.get('/api/marketing-events/status-counts')
|
||||
);
|
||||
@ -2,21 +2,6 @@ import request from './request';
|
||||
|
||||
const PLANNING_TABLE_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
export const getOpsBIncentiveBatches = () => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive/batches',
|
||||
{ timeout: 60 * 1000 }
|
||||
);
|
||||
|
||||
export const getOpsBIncentiveBoard = (params = {}) => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive',
|
||||
{ params, timeout: PLANNING_TABLE_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const getOpsBIncentiveDaily = (params = {}) => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive/daily',
|
||||
{ params, timeout: PLANNING_TABLE_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const getPlanningAlibaba1688ProductDetail = (productId) => {
|
||||
return request.get(`/api/planning-upload/selection-fission/1688-products/${productId}`, {
|
||||
headers: {
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
let productAnalysisMetaInFlight = null;
|
||||
const productAnalysisProductsInFlight = new Map();
|
||||
|
||||
export const getProductAnalysisMeta = () => {
|
||||
if (!productAnalysisMetaInFlight) {
|
||||
productAnalysisMetaInFlight = request.get('/api/product-analysis/meta')
|
||||
.finally(() => {
|
||||
productAnalysisMetaInFlight = null;
|
||||
});
|
||||
}
|
||||
return productAnalysisMetaInFlight;
|
||||
};
|
||||
|
||||
export const getProductAnalysisSignalThresholds = () => (
|
||||
request.get('/api/product-analysis/signal-thresholds', { silentError: true })
|
||||
);
|
||||
|
||||
export const saveProductAnalysisSignalThresholds = (thresholds) => (
|
||||
request.put('/api/product-analysis/signal-thresholds', { thresholds }, { silentError: true })
|
||||
);
|
||||
|
||||
export const getDevelopmentDashboard = (params, config = {}) => (
|
||||
request.get('/api/product-analysis/dashboard', { ...config, params })
|
||||
);
|
||||
|
||||
export const getProductAnalysisProducts = (params) => {
|
||||
const requestKey = JSON.stringify(params || {});
|
||||
if (!productAnalysisProductsInFlight.has(requestKey)) {
|
||||
const inFlight = request.get('/api/product-analysis/products', { params })
|
||||
.finally(() => {
|
||||
productAnalysisProductsInFlight.delete(requestKey);
|
||||
});
|
||||
productAnalysisProductsInFlight.set(requestKey, inFlight);
|
||||
}
|
||||
return productAnalysisProductsInFlight.get(requestKey);
|
||||
};
|
||||
|
||||
export const getProductAnalysisTab = (goodsId, tabKey, params) => (
|
||||
request.get(`/api/product-analysis/products/${encodeURIComponent(goodsId)}/tabs/${tabKey}`, { params })
|
||||
);
|
||||
@ -1,34 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const META_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
// 明细列表(公司标签 / 部门标签 / 负责人 / 汇总)
|
||||
export const getProductMonitoringList = (params = {}) => request.get(
|
||||
'/api/product-monitoring/list',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 筛选下拉选项(品类 / 供应商 / 仓库 / 采购负责人 / 销售状态)
|
||||
export const getProductMonitoringFilters = () => request.get(
|
||||
'/api/product-monitoring/filters',
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||
export const getProductMonitoringOperators = (sku) => request.get(
|
||||
'/api/product-monitoring/operators',
|
||||
{ params: { sku }, timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 部门标签(G-S/G-A/G-B/G-C)+ 部门 SKU 负责人配置
|
||||
export const getDepartmentTagConfig = () => request.get(
|
||||
'/api/product-monitoring/department-config',
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const saveDepartmentTagConfig = (payload = {}) => request.put(
|
||||
'/api/product-monitoring/department-config',
|
||||
payload,
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
@ -1,17 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
export const getPromotionModuleList = (params) => (
|
||||
request.get('/api/promotion-module/list', { params })
|
||||
);
|
||||
|
||||
export const getPromotionModuleLatestDate = () => (
|
||||
request.get('/api/promotion-module/latest-date')
|
||||
);
|
||||
|
||||
export const getPromotionModuleSummary = (params) => (
|
||||
request.get('/api/promotion-module/summary', { params })
|
||||
);
|
||||
|
||||
export const getPromotionModuleShops = () => (
|
||||
request.get('/api/promotion-module/shops')
|
||||
);
|
||||
@ -2,22 +2,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
collapsedByDefault: false,
|
||||
darkModeByDefault: false,
|
||||
entries: [
|
||||
{ key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
|
||||
{ key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
|
||||
{ key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
|
||||
{
|
||||
key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
|
||||
{ key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
|
||||
{
|
||||
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
|
||||
@ -45,28 +30,6 @@ const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
|
||||
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||
const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||
|
||||
// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
|
||||
const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
|
||||
const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
|
||||
const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
|
||||
// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
|
||||
const extractPromotedTopLevelEntries = (entries = []) => {
|
||||
const promoted = [];
|
||||
const nextEntries = entries.map((entry) => {
|
||||
if (!Array.isArray(entry?.children)) return entry;
|
||||
const children = [];
|
||||
entry.children.forEach((child) => {
|
||||
if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
|
||||
promoted.push(child);
|
||||
} else {
|
||||
children.push(child);
|
||||
}
|
||||
});
|
||||
return children.length === entry.children.length ? entry : { ...entry, children };
|
||||
});
|
||||
return { nextEntries, promoted };
|
||||
};
|
||||
|
||||
const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
|
||||
const configuredKeys = new Set();
|
||||
@ -82,18 +45,6 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
|
||||
merged.requiresAdmin = defaultEntry.requiresAdmin;
|
||||
}
|
||||
if (
|
||||
defaultEntry.key === 'ops-b-incentive'
|
||||
&& (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
|
||||
) {
|
||||
merged.label = defaultEntry.label;
|
||||
}
|
||||
if (
|
||||
defaultEntry.key === 'ops-b-incentive'
|
||||
&& (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
|
||||
) {
|
||||
merged.order = defaultEntry.order;
|
||||
}
|
||||
const hasDefaultChildren = Array.isArray(defaultEntry.children);
|
||||
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
|
||||
|
||||
@ -130,15 +81,10 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||
|
||||
const mergeSidebarConfigWithDefaults = (config) => {
|
||||
const defaults = cloneDefaultConfig();
|
||||
const nestedEntries = (config?.entries || []);
|
||||
const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
|
||||
const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
|
||||
const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...config,
|
||||
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
|
||||
entries: mergeSidebarEntries(defaults.entries, config?.entries || []),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -72,15 +72,6 @@ const SIDEBAR_ICON_MAP = {
|
||||
|
||||
const SIDEBAR_ROUTE_MAP = {
|
||||
'dashboard-preview': { path: '/planning/dev-style-preview' },
|
||||
'marketing-events': {
|
||||
path: '/planning/operation/marketing-events',
|
||||
activePath: '/planning/operation/marketing-events',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'promotion-module': { path: '/planning/operation/promotion-module' },
|
||||
'product-monitoring': { path: '/planning/operation/product-monitoring' },
|
||||
'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' },
|
||||
'product-analysis': { path: '/planning/operation/product-analysis' },
|
||||
'work-orders': { path: '/planning/operation/work-orders' },
|
||||
settings: { path: '/settings' },
|
||||
'online-desktop-users': { path: '/settings/online-desktop-users' },
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
SycmSimpleTable,
|
||||
SycmSparkline,
|
||||
} from '../../components/Common';
|
||||
import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis';
|
||||
import { getDashboardMeta, getDashboardSummary } from '../../api/dashboard';
|
||||
import './stylePreview.css';
|
||||
|
||||
const overviewMetrics = [
|
||||
@ -180,7 +180,7 @@ const DevelopmentStylePreview = () => {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getProductAnalysisMeta().then((response) => {
|
||||
getDashboardMeta().then((response) => {
|
||||
if (!active) return;
|
||||
const shops = Array.isArray(response?.data?.shops) ? response.data.shops : [];
|
||||
setShopOptions([
|
||||
@ -202,7 +202,7 @@ const DevelopmentStylePreview = () => {
|
||||
const params = customStartDate && customEndDate
|
||||
? { startDate: customStartDate, endDate: customEndDate, shopName: shopName || undefined }
|
||||
: { period: PERIOD_MAP[activePeriod] || 'week', shopName: shopName || undefined };
|
||||
getDevelopmentDashboard(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
|
||||
getDashboardSummary(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
|
||||
const payload = response?.data || {};
|
||||
setDashboard(payload);
|
||||
}).catch((error) => {
|
||||
|
||||
@ -1,492 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Segmented,
|
||||
Select,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { SycmBusinessPanel, SycmDashboardMetric, SycmPage } from '../../components/Common';
|
||||
import {
|
||||
getMarketingEventsList,
|
||||
getMarketingEventsLatestDate,
|
||||
getMarketingEventsSummary,
|
||||
} from '../../api/marketingEvents';
|
||||
import './style.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '-';
|
||||
const num = Number(ts);
|
||||
if (!Number.isFinite(num)) return '-';
|
||||
const date = new Date(num > 1e12 ? num : num * 1000);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function formatPrice(value) {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '-';
|
||||
return `¥${num.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '-';
|
||||
return num.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
const STATUS_MAP = {
|
||||
501: { label: '报名成功', color: 'success' },
|
||||
502: { label: '审核中', color: 'processing' },
|
||||
503: { label: '已拒绝', color: 'error' },
|
||||
504: { label: '已取消', color: 'default' },
|
||||
505: { label: '已结束', color: 'warning' },
|
||||
};
|
||||
|
||||
function getStatusInfo(status) {
|
||||
if (STATUS_MAP[status]) return STATUS_MAP[status];
|
||||
return { label: `状态 ${status}`, color: 'default' };
|
||||
}
|
||||
|
||||
const MarketingEvents = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusCounts, setStatusCounts] = useState({ all: 0 });
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
|
||||
const [sorter, setSorter] = useState({ field: 'activity_goods_start_time', order: 'descend' });
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [groupByGoods, setGroupByGoods] = useState(true);
|
||||
const [queryFilters, setQueryFilters] = useState({});
|
||||
const [latestDate, setLatestDate] = useState('');
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
|
||||
const fetchLatestDate = useCallback(async () => {
|
||||
try {
|
||||
const res = await getMarketingEventsLatestDate();
|
||||
if (res?.data?.date) {
|
||||
setLatestDate(res.data.date);
|
||||
return res.data.date;
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
return '';
|
||||
}, []);
|
||||
|
||||
const fetchSummary = useCallback(async (date) => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const res = await getMarketingEventsSummary({ date });
|
||||
if (res?.data) {
|
||||
setSummary(res.data);
|
||||
}
|
||||
} catch {
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLatestDate().then((date) => {
|
||||
if (date) fetchSummary(date);
|
||||
});
|
||||
}, [fetchLatestDate, fetchSummary]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: sorter.field,
|
||||
sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||
...queryFilters,
|
||||
};
|
||||
if (statusFilter !== 'all') {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
const result = await getMarketingEventsList(params);
|
||||
const data = result?.data;
|
||||
if (data) {
|
||||
setDataSource(data.list || []);
|
||||
setTotal(data.total || 0);
|
||||
if (data.statusCounts) {
|
||||
setStatusCounts(data.statusCounts);
|
||||
}
|
||||
if (data.filterDate) {
|
||||
setLatestDate(data.filterDate);
|
||||
fetchSummary(data.filterDate);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setDataSource([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination, sorter, statusFilter, queryFilters, fetchSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleTableChange = (pag, _filters, sort) => {
|
||||
setPagination({ current: pag.current, pageSize: pag.pageSize });
|
||||
if (sort.field) {
|
||||
setSorter({ field: sort.field, order: sort.order || 'descend' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
setQueryFilters({});
|
||||
setStatusFilter('all');
|
||||
setPagination({ current: 1, pageSize: 20 });
|
||||
setSorter({ field: 'activity_goods_start_time', order: 'descend' });
|
||||
};
|
||||
|
||||
const handleFinish = (values) => {
|
||||
const filters = {};
|
||||
if (values.shopName) filters.shopName = values.shopName;
|
||||
if (values.activityType) filters.activityType = values.activityType;
|
||||
if (values.activityName) filters.activityName = values.activityName;
|
||||
if (values.activityId) filters.activityId = values.activityId;
|
||||
if (values.goodsName) filters.goodsName = values.goodsName;
|
||||
if (values.goodsId) filters.goodsId = values.goodsId;
|
||||
if (values.dateRange?.length === 2) {
|
||||
filters.dateStart = values.dateRange[0].format('YYYY-MM-DD');
|
||||
filters.dateEnd = values.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
setQueryFilters(filters);
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
};
|
||||
|
||||
const statusOptions = useMemo(() => [
|
||||
{ label: `全部 ${statusCounts.all || 0}`, value: 'all' },
|
||||
{ label: `报名成功 ${statusCounts[501] || 0}`, value: 501 },
|
||||
{ label: `审核中 ${statusCounts[502] || 0}`, value: 502 },
|
||||
{ label: `已拒绝 ${statusCounts[503] || 0}`, value: 503 },
|
||||
{ label: `已取消 ${statusCounts[504] || 0}`, value: 504 },
|
||||
{ label: `已结束 ${statusCounts[505] || 0}`, value: 505 },
|
||||
], [statusCounts]);
|
||||
|
||||
// 按商品聚合:同一商品多个活动时,最低价活动作为主行优先展示,其余折叠进展开行
|
||||
const displayData = useMemo(() => {
|
||||
const priceOf = (row) => {
|
||||
const num = Number(row?.minPrice);
|
||||
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
if (!groupByGoods) {
|
||||
return dataSource.map((row) => ({ ...row, activityCount: 1, childActivities: null }));
|
||||
}
|
||||
const groups = new Map();
|
||||
dataSource.forEach((row) => {
|
||||
const key = String(row.goodsId || row.id);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(row);
|
||||
});
|
||||
const list = [];
|
||||
groups.forEach((rows) => {
|
||||
const sorted = [...rows].sort((left, right) => priceOf(left) - priceOf(right));
|
||||
const [primary, ...rest] = sorted;
|
||||
list.push({
|
||||
...primary,
|
||||
activityCount: sorted.length,
|
||||
childActivities: rest.length ? rest : null,
|
||||
});
|
||||
});
|
||||
return list;
|
||||
}, [dataSource, groupByGoods]);
|
||||
|
||||
const summaryMetrics = useMemo(() => {
|
||||
if (!summary) return [];
|
||||
return [
|
||||
{ key: 'totalRecords', label: '活动记录数', value: formatNumber(summary.totalRecords) },
|
||||
{ key: 'successCount', label: '报名成功', value: formatNumber(summary.successCount) },
|
||||
{ key: 'successRate', label: '报名成功率', value: `${summary.successRate}%` },
|
||||
{ key: 'goodsCount', label: '参与商品数', value: formatNumber(summary.goodsCount) },
|
||||
{ key: 'shopCount', label: '参与店铺数', value: formatNumber(summary.shopCount) },
|
||||
{ key: 'activityIdCount', label: '活动数', value: formatNumber(summary.activityIdCount) },
|
||||
{ key: 'activityTypeCount', label: '活动类型', value: formatNumber(summary.activityTypeCount) },
|
||||
{ key: 'priceRange', label: '价格区间', value: summary.globalMinPrice !== '-' ? `${formatPrice(summary.globalMinPrice)} ~ ${formatPrice(summary.globalMaxPrice)}` : '-' },
|
||||
];
|
||||
}, [summary]);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '商品图片',
|
||||
dataIndex: 'thumbUrl',
|
||||
key: 'thumbUrl',
|
||||
width: 80,
|
||||
fixed: 'left',
|
||||
render: (value, record) => (
|
||||
value ? (
|
||||
<img
|
||||
className="marketing-events-thumb"
|
||||
src={value}
|
||||
alt={record.goodsName || '商品图片'}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="marketing-events-thumb is-empty">暂无</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'goodsName',
|
||||
key: 'goodsName',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (value, record) => (
|
||||
<div className="marketing-events-goods-cell">
|
||||
{value ? <span className="marketing-events-goods-name" title={value}>{value}</span> : '-'}
|
||||
<span className="marketing-events-goods-id">ID: {record.goodsId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '活动名称',
|
||||
dataIndex: 'activityName',
|
||||
key: 'activityName',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (value, record) => (
|
||||
<div className="marketing-events-goods-cell">
|
||||
{value ? <span title={value}>{value}</span> : '-'}
|
||||
<span className="marketing-events-goods-id">ID: {record.activityId || '-'}</span>
|
||||
{record.activityCount > 1 ? (
|
||||
<Tag color="blue" style={{ marginRight: 0 }}>共{record.activityCount}个活动 · 已按最低价优先</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '活动类型',
|
||||
dataIndex: 'activityType',
|
||||
key: 'activityType',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '店铺',
|
||||
dataIndex: 'shopName',
|
||||
key: 'shopName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '报名时间',
|
||||
dataIndex: 'enrollTime',
|
||||
key: 'enrollTime',
|
||||
width: 160,
|
||||
sorter: true,
|
||||
render: (value) => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '活动开始',
|
||||
dataIndex: 'activityStartTime',
|
||||
key: 'activityStartTime',
|
||||
width: 160,
|
||||
sorter: true,
|
||||
render: (value) => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '活动结束',
|
||||
dataIndex: 'activityEndTime',
|
||||
key: 'activityEndTime',
|
||||
width: 160,
|
||||
sorter: true,
|
||||
render: (value) => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '最低价',
|
||||
dataIndex: 'minPrice',
|
||||
key: 'minPrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => formatPrice(value),
|
||||
},
|
||||
{
|
||||
title: '最高价',
|
||||
dataIndex: 'maxPrice',
|
||||
key: 'maxPrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => formatPrice(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (value) => {
|
||||
const info = getStatusInfo(value);
|
||||
return <Tag color={info.color}>{info.label}</Tag>;
|
||||
},
|
||||
},
|
||||
], []);
|
||||
|
||||
const childActivityColumns = [
|
||||
{ title: '活动名称', dataIndex: 'activityName', width: 200, ellipsis: true, render: (value, record) => (
|
||||
<div className="marketing-events-goods-cell">
|
||||
{value ? <span title={value}>{value}</span> : '-'}
|
||||
<span className="marketing-events-goods-id">ID: {record.activityId || '-'}</span>
|
||||
</div>
|
||||
) },
|
||||
{ title: '活动类型', dataIndex: 'activityType', width: 140, ellipsis: true, render: (value) => value || '-' },
|
||||
{ title: '报名时间', dataIndex: 'enrollTime', width: 160, render: formatTimestamp },
|
||||
{ title: '活动开始', dataIndex: 'activityStartTime', width: 160, render: formatTimestamp },
|
||||
{ title: '活动结束', dataIndex: 'activityEndTime', width: 160, render: formatTimestamp },
|
||||
{ title: '最低价', dataIndex: 'minPrice', width: 100, align: 'right', render: formatPrice },
|
||||
{ title: '最高价', dataIndex: 'maxPrice', width: 100, align: 'right', render: formatPrice },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (value) => {
|
||||
const info = getStatusInfo(value);
|
||||
return <Tag color={info.color}>{info.label}</Tag>;
|
||||
} },
|
||||
];
|
||||
|
||||
return (
|
||||
<SycmPage className="marketing-events-page">
|
||||
<SycmBusinessPanel className="marketing-events-panel" aria-label="营销活动内容区">
|
||||
<div className="marketing-events-panel__heading">
|
||||
<h2>营销活动</h2>
|
||||
<span>查看拼多多平台活动报名记录</span>
|
||||
{latestDate && (
|
||||
<span className="marketing-events-data-date">最新数据日期:{latestDate}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Spin spinning={summaryLoading}>
|
||||
<div className="marketing-events-summary">
|
||||
{summaryMetrics.map((item) => (
|
||||
<SycmDashboardMetric
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
help={false}
|
||||
/>
|
||||
))}
|
||||
{!summary && !summaryLoading && (
|
||||
<span className="marketing-events-summary__empty">暂无汇总数据</span>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="marketing-events-filter"
|
||||
layout="horizontal"
|
||||
colon={false}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
<Form.Item label="店铺名称" name="shopName">
|
||||
<Input allowClear placeholder="输入店铺名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="活动类型" name="activityType">
|
||||
<Input allowClear placeholder="输入活动类型" />
|
||||
</Form.Item>
|
||||
<Form.Item label="活动名称" name="activityName">
|
||||
<Input allowClear placeholder="输入活动名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="活动ID" name="activityId">
|
||||
<Input allowClear placeholder="输入活动ID" />
|
||||
</Form.Item>
|
||||
<Form.Item label="商品名称" name="goodsName">
|
||||
<Input allowClear placeholder="输入商品名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="商品ID" name="goodsId">
|
||||
<Input allowClear placeholder="输入商品ID" />
|
||||
</Form.Item>
|
||||
<Form.Item label="活动时间" name="dateRange">
|
||||
<RangePicker allowClear placeholder={['开始日期', '结束日期']} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<div className="marketing-events-filter__actions">
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>查询</Button>
|
||||
<Button htmlType="button" icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="marketing-events-table-toolbar">
|
||||
<Segmented
|
||||
className="marketing-events-status-switch"
|
||||
value={statusFilter}
|
||||
options={statusOptions}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(value);
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
}}
|
||||
/>
|
||||
<Segmented
|
||||
value={groupByGoods ? 'group' : 'flat'}
|
||||
options={[
|
||||
{ label: '按商品聚合(最低价优先)', value: 'group' },
|
||||
{ label: '按活动明细', value: 'flat' },
|
||||
]}
|
||||
onChange={(value) => setGroupByGoods(value === 'group')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
className="marketing-events-table"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={displayData}
|
||||
onChange={handleTableChange}
|
||||
expandable={groupByGoods ? {
|
||||
expandedRowRender: (record) => (
|
||||
<Table
|
||||
className="marketing-events-child-table"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={childActivityColumns}
|
||||
dataSource={record.childActivities || []}
|
||||
/>
|
||||
),
|
||||
rowExpandable: (record) => Boolean(record.childActivities?.length),
|
||||
} : undefined}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 1600 }}
|
||||
locale={{ emptyText: loading ? '加载中...' : '暂无活动记录' }}
|
||||
size="small"
|
||||
/>
|
||||
</Spin>
|
||||
</SycmBusinessPanel>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketingEvents;
|
||||
@ -1,224 +0,0 @@
|
||||
.marketing-events-panel {
|
||||
min-height: calc(100vh - 84px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.marketing-events-panel__heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding: var(--sycm-module-padding) var(--sycm-module-padding) 0;
|
||||
}
|
||||
|
||||
.marketing-events-panel__heading h2 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: var(--sycm-line-height-tight);
|
||||
}
|
||||
|
||||
.marketing-events-panel__heading span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.marketing-events-data-date {
|
||||
margin-left: auto;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marketing-events-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
gap: var(--sycm-space-sm);
|
||||
margin-top: var(--sycm-space-sm);
|
||||
padding: 0 var(--sycm-module-padding);
|
||||
}
|
||||
|
||||
.marketing-events-summary .sycm-dashboard-metric {
|
||||
min-height: 88px;
|
||||
padding: var(--sycm-space-sm);
|
||||
border-radius: var(--sycm-radius-lg);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.marketing-events-summary .sycm-dashboard-metric__label {
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.marketing-events-summary .sycm-dashboard-metric__value {
|
||||
font-size: var(--sycm-font-size-metric);
|
||||
}
|
||||
|
||||
.marketing-events-summary__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: var(--sycm-space-md);
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.marketing-events-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: var(--sycm-space-sm) var(--sycm-space-xl);
|
||||
margin-top: var(--sycm-space-sm);
|
||||
padding: var(--sycm-module-padding);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item-label {
|
||||
padding: 0 var(--sycm-space-sm) 0 0;
|
||||
overflow: visible;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item-label > label {
|
||||
height: var(--sycm-control-height);
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
line-height: var(--sycm-control-height);
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item-control,
|
||||
.marketing-events-filter .ant-form-item-control-input,
|
||||
.marketing-events-filter .ant-form-item-control-input-content {
|
||||
min-height: var(--sycm-control-height);
|
||||
}
|
||||
|
||||
.marketing-events-filter__actions {
|
||||
display: flex;
|
||||
min-height: var(--sycm-control-height);
|
||||
align-items: center;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding-left: 72px;
|
||||
}
|
||||
|
||||
.marketing-events-table-toolbar {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-md);
|
||||
padding: var(--sycm-space-sm) var(--sycm-module-padding);
|
||||
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.marketing-events-status-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.marketing-events-child-table {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.marketing-events-child-table .ant-table {
|
||||
background: var(--sycm-bg-page-soft, #fafafa);
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-wrapper,
|
||||
.marketing-events-table .ant-spin-nested-loading,
|
||||
.marketing-events-table .ant-spin-container {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-cell {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-thead > tr > th {
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-tbody > tr > td {
|
||||
color: var(--sycm-text-primary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-placeholder .ant-table-cell {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.marketing-events-thumb {
|
||||
display: block;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.marketing-events-thumb.is-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: var(--sycm-bg-subtle);
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.marketing-events-goods-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-xxs);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.marketing-events-goods-name {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-strong);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marketing-events-goods-id {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.marketing-events-panel__heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-xxs);
|
||||
}
|
||||
|
||||
.marketing-events-filter {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.marketing-events-filter__actions {
|
||||
padding-left: 72px;
|
||||
}
|
||||
}
|
||||
@ -1,233 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Col, DatePicker, Descriptions, Drawer, Dropdown, Input, Row, Select,
|
||||
Space, Spin, Statistic, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import { getOpsBIncentiveBatches, getOpsBIncentiveBoard, getOpsBIncentiveDaily } from '../../api/planningUpload';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './opsBIncentiveBoard.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const FALLBACK_BATCH = { key: 'ops-b-20260715-20260721', batchNo: 1, scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 };
|
||||
const batchLabel = (item) => {
|
||||
const mmdd = (value) => dayjs(value).format('MM-DD');
|
||||
const cap = `${parseFloat((Number(item.feeCap) * 100).toFixed(1))}%`;
|
||||
return `第${item.batchNo}批(${mmdd(item.scopeStart)} 至 ${mmdd(item.scopeEnd)} · 拉取 ${mmdd(item.pullDate)} · 费比≤${cap})${item.finalized ? ' · 已定档' : ''}`;
|
||||
};
|
||||
|
||||
const number = (value, digits = 2) => value === null || value === undefined || value === ''
|
||||
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
const money = (value) => value === null || value === undefined ? '--' : `¥${number(value)}`;
|
||||
const percent = (value) => value === null || value === undefined ? '--' : `${(Number(value) * 100).toFixed(2)}%`;
|
||||
const dateText = (value) => value ? dayjs(value).format('YYYY-MM-DD') : '--';
|
||||
|
||||
const OpsBIncentiveBoardPanel = () => {
|
||||
const [batches, setBatches] = useState([]);
|
||||
const [batch, setBatch] = useState(FALLBACK_BATCH.key);
|
||||
const [metricEnd, setMetricEnd] = useState(dayjs(FALLBACK_BATCH.pullDate));
|
||||
const [filters, setFilters] = useState({ ider: '', shopName: '', spu: '', goodsId: '' });
|
||||
const [data, setData] = useState({ rows: [], ownerSummary: [], summary: {} });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [dailyRows, setDailyRows] = useState([]);
|
||||
const [dailyLoading, setDailyLoading] = useState(false);
|
||||
|
||||
const selectedBatch = useMemo(
|
||||
() => batches.find((item) => item.key === batch) || FALLBACK_BATCH,
|
||||
[batches, batch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getOpsBIncentiveBatches()
|
||||
.then((result) => {
|
||||
const list = result?.data?.data || result?.data || [];
|
||||
if (cancelled || !Array.isArray(list) || !list.length) return;
|
||||
setBatches(list);
|
||||
if (!list.some((item) => item.key === FALLBACK_BATCH.key)) {
|
||||
setBatch(list[0].key);
|
||||
setMetricEnd(dayjs(list[0].pullDate));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const query = useMemo(() => ({
|
||||
scopeStart: selectedBatch.scopeStart,
|
||||
scopeEnd: selectedBatch.scopeEnd,
|
||||
metricEnd: metricEnd?.format('YYYY-MM-DD') || selectedBatch.pullDate,
|
||||
...filters,
|
||||
}), [selectedBatch, metricEnd, filters]);
|
||||
|
||||
const changeBatch = (value) => {
|
||||
setBatch(value);
|
||||
const next = batches.find((item) => item.key === value);
|
||||
if (next) setMetricEnd(dayjs(next.pullDate));
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const result = await getOpsBIncentiveBoard(query);
|
||||
setData(result?.data || result || { rows: [], ownerSummary: [], summary: {} });
|
||||
} catch (requestError) {
|
||||
setError(requestError?.message || '加载运营中心B看板失败');
|
||||
} finally { setLoading(false); }
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openDetail = async (record) => {
|
||||
setSelected(record); setDailyRows([]); setDailyLoading(true);
|
||||
try {
|
||||
const result = await getOpsBIncentiveDaily({ ...query, goodsId: record.goodsId });
|
||||
setDailyRows(result?.data?.rows || result?.rows || []);
|
||||
} catch (requestError) {
|
||||
message.error(requestError?.message || '加载每日明细失败');
|
||||
} finally { setDailyLoading(false); }
|
||||
};
|
||||
|
||||
const iderOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.ider).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]);
|
||||
const shopOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.shopName).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]);
|
||||
// 分组:0=动销率与费比均达标,1=仅一项达标,2=均不达标;组内按动销率降序。
|
||||
const sortedRows = useMemo(() => [...(data.rows || [])].sort((a, b) => {
|
||||
const group = (row) => (row.bonusQualified ? 0 : (row.feeQualified || row.sellThroughQualified ? 1 : 2));
|
||||
const rate = (row) => (row.sellThroughRate30d === null || row.sellThroughRate30d === undefined ? -1 : row.sellThroughRate30d);
|
||||
return group(a) - group(b) || rate(b) - rate(a) || String(a.goodsId).localeCompare(String(b.goodsId));
|
||||
}), [data.rows]);
|
||||
const num = (value) => (value === null || value === undefined || value === '' ? -Infinity : Number(value));
|
||||
const qualifyFilters = [
|
||||
{ text: '达标', value: 'yes' },
|
||||
{ text: '未达标', value: 'no' },
|
||||
];
|
||||
const qualifyFilter = (field) => ({
|
||||
filters: qualifyFilters,
|
||||
onFilter: (value, row) => (value === 'yes' ? Boolean(row[field]) : !row[field]),
|
||||
});
|
||||
const columns = [
|
||||
{ title: 'ID负责人', dataIndex: 'ider', fixed: 'left', width: 100 },
|
||||
{ title: '商品ID', dataIndex: 'goodsId', fixed: 'left', width: 130, render: (value, row) => <Button type="link" className="ops-b-id-link" onClick={() => openDetail(row)}>{value}</Button> },
|
||||
{ title: '商品名称', dataIndex: 'goodsName', width: 220, ellipsis: true },
|
||||
{ title: '店铺名称', dataIndex: 'shopName', width: 150, ellipsis: true },
|
||||
{ title: 'SPU', dataIndex: 'spu', width: 120 },
|
||||
{ title: '链接类型', dataIndex: 'linkType', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (v) => <Tag color={v === '在售' ? 'green' : 'default'}>{v}</Tag> },
|
||||
{ title: 'SKU数', dataIndex: 'skuCount', width: 80, align: 'right', sorter: (a, b) => num(a.skuCount) - num(b.skuCount) },
|
||||
{ title: '上架时间', dataIndex: 'firstCreatedAt', width: 120, render: dateText, exportValue: dateText },
|
||||
{ title: '上架天数', dataIndex: 'listingDays', width: 90, align: 'right', sorter: (a, b) => num(a.listingDays) - num(b.listingDays), render: (v) => number(v, 0) },
|
||||
{ title: '近30天ID动销率', dataIndex: 'sellThroughRate30d', width: 130, align: 'right', sorter: (a, b) => num(a.sellThroughRate30d) - num(b.sellThroughRate30d), render: percent, exportValue: percent },
|
||||
{ title: '动销率是否达标', dataIndex: 'sellThroughQualified', width: 120, ...qualifyFilter('sellThroughQualified'), render: (v) => <Tag color={v ? 'green' : 'red'}>{v ? '达标' : '未达标'}</Tag>, exportValue: (v) => (v ? '达标' : '未达标') },
|
||||
{ title: '近30天费比', dataIndex: 'feeRatio30d', width: 110, align: 'right', sorter: (a, b) => num(a.feeRatio30d) - num(b.feeRatio30d), render: percent, exportValue: percent },
|
||||
{ title: '费比是否达标', dataIndex: 'feeQualified', width: 105, ...qualifyFilter('feeQualified'), render: (v) => <Tag color={v ? 'green' : 'red'}>{v ? '达标' : '未达标'}</Tag>, exportValue: (v) => (v ? '达标' : '未达标') },
|
||||
{ title: '近30天真实业绩', dataIndex: 'performance30d', width: 135, align: 'right', sorter: (a, b) => num(a.performance30d) - num(b.performance30d), render: money },
|
||||
{ title: '近30天真实费用', dataIndex: 'fee30d', width: 135, align: 'right', sorter: (a, b) => num(a.fee30d) - num(b.fee30d), render: money },
|
||||
{ title: '近30天发货件数', dataIndex: 'goodsCount30d', width: 125, align: 'right', sorter: (a, b) => num(a.goodsCount30d) - num(b.goodsCount30d), render: (v) => number(v, 0) },
|
||||
{ title: '近30天发货天数', dataIndex: 'shippedDays30d', width: 125, align: 'right', sorter: (a, b) => num(a.shippedDays30d) - num(b.shippedDays30d), render: (v) => number(v, 0) },
|
||||
{ title: '应发奖金', dataIndex: 'bonus', width: 100, align: 'right', sorter: (a, b) => num(a.bonus) - num(b.bonus), render: money },
|
||||
];
|
||||
|
||||
const ownerSummaryColumns = [
|
||||
{ title: 'ID负责人', dataIndex: 'ider' },
|
||||
{ title: '商品ID数', dataIndex: 'goodsIdCount' },
|
||||
{ title: '近30天发货件数', dataIndex: 'goodsCount30d', render: (v) => number(v, 0) },
|
||||
{ title: '费比达标', dataIndex: 'feeQualifiedCount' },
|
||||
{ title: '奖金达标', dataIndex: 'bonusQualifiedCount' },
|
||||
{ title: '应发奖金', dataIndex: 'bonus', render: money },
|
||||
];
|
||||
|
||||
const exportFileName = (label) => (
|
||||
`激励看板_${label}_${selectedBatch.scopeStart}_${selectedBatch.scopeEnd}_统计至${metricEnd?.format('YYYYMMDD') || '最新'}`
|
||||
);
|
||||
|
||||
const handleExport = async (scope, format) => {
|
||||
const isSummary = scope === 'summary';
|
||||
const exportColumns = isSummary ? ownerSummaryColumns : columns;
|
||||
const exportRows = isSummary ? (data.ownerSummary || []) : sortedRows;
|
||||
if (!exportRows.length) {
|
||||
message.warning(isSummary ? '负责人汇总暂无数据可导出' : 'ID核心明细暂无数据可导出');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await exportTableFile(format, exportFileName(isSummary ? '负责人汇总' : 'ID核心明细'), exportColumns, exportRows, isSummary ? '负责人汇总' : 'ID核心明细');
|
||||
message.success(`已导出 ${exportRows.length} 条数据(${format.toUpperCase()})`);
|
||||
} catch (exportError) {
|
||||
message.error(exportError?.message || '导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const exportDropdown = (scope) => (
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'xlsx', label: '导出 XLSX' },
|
||||
{ key: 'csv', label: '导出 CSV' },
|
||||
],
|
||||
onClick: ({ key }) => handleExport(scope, key),
|
||||
}}>
|
||||
<Button icon={<DownloadOutlined />}>导出</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
const summary = data.summary || {};
|
||||
return (
|
||||
<>
|
||||
<Card className="ops-b-filter-card" bordered={false}>
|
||||
<Space wrap size={[12, 12]}>
|
||||
<Select
|
||||
value={batch}
|
||||
options={(batches.length ? batches : [FALLBACK_BATCH]).map((item) => ({ value: item.key, label: batchLabel(item) }))}
|
||||
onChange={changeBatch}
|
||||
style={{ width: 360 }}
|
||||
/>
|
||||
<DatePicker value={metricEnd} allowClear={false} onChange={setMetricEnd} placeholder="统计截止日" />
|
||||
<Select allowClear showSearch placeholder="ID负责人" options={iderOptions} value={filters.ider || undefined} onChange={(value) => setFilters((old) => ({ ...old, ider: value || '' }))} style={{ width: 150 }} />
|
||||
<Select allowClear showSearch placeholder="店铺名称" options={shopOptions} value={filters.shopName || undefined} onChange={(value) => setFilters((old) => ({ ...old, shopName: value || '' }))} style={{ width: 180 }} />
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="SPU" value={filters.spu} onChange={(e) => setFilters((old) => ({ ...old, spu: e.target.value }))} style={{ width: 150 }} />
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="商品ID" value={filters.goodsId} onChange={(e) => setFilters((old) => ({ ...old, goodsId: e.target.value }))} style={{ width: 170 }} />
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||
<Row gutter={[12, 12]} className="ops-b-summary-row">
|
||||
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-blue"><Statistic title="商品ID数" value={summary.goodsIdCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-green"><Statistic title="费比达标ID" value={summary.feeQualifiedCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-orange"><Statistic title="动销率 > 30%" value={summary.sellThroughQualifiedCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-gold"><Statistic title="应发奖金" value={summary.bonus || 0} prefix="¥" precision={2} /></Card></Col>
|
||||
</Row>
|
||||
<Card bordered={false} className="ops-b-panel" title="负责人汇总" extra={exportDropdown('summary')}>
|
||||
<Table rowKey="ider" size="small" pagination={false} dataSource={data.ownerSummary || []} columns={ownerSummaryColumns} />
|
||||
</Card>
|
||||
<Card bordered={false} className="ops-b-panel" title={`ID核心明细(${(data.rows || []).length} 条)`} extra={exportDropdown('detail')}>
|
||||
<Spin spinning={loading} tip="正在加载运营中心B数据…"><Table rowKey="goodsId" size="small" columns={columns} dataSource={sortedRows} pagination={false} scroll={{ x: 2400, y: 'min(520px, calc(100vh - 460px))' }} /></Spin>
|
||||
</Card>
|
||||
<Drawer className="ops-b-detail-drawer" title={selected ? `${selected.goodsId} · 每日发货明细` : '每日发货明细'} width={760} open={Boolean(selected)} onClose={() => setSelected(null)}>
|
||||
{selected ? <><Descriptions size="small" bordered column={2} items={[{ key: 'name', label: '商品名称', children: selected.goodsName || '--' }, { key: 'owner', label: '负责人', children: selected.ider || '--' }, { key: 'fee', label: '近30天费比', children: percent(selected.feeRatio30d) }, { key: 'bonus', label: '奖金状态', children: selected.bonusQualified ? <Tag color="gold">达标 ¥{AWARD}</Tag> : <Tag>未达标</Tag> }]} /><Table className="ops-b-daily-table" loading={dailyLoading} rowKey="statDate" size="small" pagination={false} dataSource={dailyRows} columns={[{ title: '日期', dataIndex: 'statDate', render: dateText }, { title: '发货件数', dataIndex: 'goodsCount', align: 'right', render: (v) => number(v, 0) }, { title: '是否动销', dataIndex: 'isActive' }, { title: '真实业绩', dataIndex: 'realPerformance', align: 'right', render: money }, { title: '真实费用', dataIndex: 'realFee', align: 'right', render: money }]} /></> : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OpsBIncentiveBoard = () => (
|
||||
<SycmPage className="ops-b-incentive-page">
|
||||
<header className="ops-b-header">
|
||||
<div className="ops-b-header__heading">
|
||||
<Title level={3}>激励看板</Title>
|
||||
<Text type="secondary">按中心切换查看商品激励奖金、费比与动销达成情况</Text>
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
className="ops-b-tabs"
|
||||
defaultActiveKey="ops-b"
|
||||
items={[
|
||||
{ key: 'ops-b', label: '运营中心B', children: <OpsBIncentiveBoardPanel /> },
|
||||
]}
|
||||
/>
|
||||
</SycmPage>
|
||||
);
|
||||
|
||||
const AWARD = 400;
|
||||
export default OpsBIncentiveBoard;
|
||||
@ -1,160 +0,0 @@
|
||||
.ops-b-incentive-page {
|
||||
container-name: ops-b-incentive;
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
min-height: calc(100% - 46px);
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
background: var(--sycm-bg-page-soft);
|
||||
}
|
||||
|
||||
.ops-b-header {
|
||||
display: flex;
|
||||
min-height: 84px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-lg);
|
||||
margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0;
|
||||
padding: 14px calc(var(--sycm-page-padding-x) + 2px);
|
||||
border-bottom: 1px solid var(--sycm-border-base);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.ops-b-header__heading { min-width: 0; }
|
||||
.ops-b-header h3 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-page);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: var(--sycm-line-height-tight);
|
||||
}
|
||||
.ops-b-header__heading .ant-typography {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.ops-b-tabs .ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding: 0 calc(var(--sycm-page-padding-x) + 2px);
|
||||
border-bottom: 1px solid var(--sycm-border-base);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
.ops-b-tabs .ant-tabs-tab {
|
||||
margin: 0;
|
||||
padding: 12px 4px;
|
||||
font-size: var(--sycm-font-size-title);
|
||||
}
|
||||
.ops-b-tabs .ant-tabs-tab + .ant-tabs-tab { margin-left: 32px; }
|
||||
.ops-b-tabs .ant-tabs-content-holder,
|
||||
.ops-b-tabs .ant-tabs-content,
|
||||
.ops-b-tabs .ant-tabs-tabpane { min-height: 100%; }
|
||||
.ops-b-tabs .ant-tabs-tabpane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
padding-top: var(--sycm-page-section-gap);
|
||||
}
|
||||
|
||||
.ops-b-filter-card,
|
||||
.ops-b-panel,
|
||||
.ops-b-summary-card {
|
||||
border: 1px solid var(--sycm-border-card) !important;
|
||||
border-radius: var(--sycm-radius-lg) !important;
|
||||
box-shadow: var(--sycm-shadow-card) !important;
|
||||
}
|
||||
.ops-b-filter-card { margin-bottom: 0; }
|
||||
.ops-b-filter-card .ant-card-body { padding: 14px 16px; }
|
||||
.ops-b-filter-card .ant-space { width: 100%; }
|
||||
.ops-b-filter-card .ant-space-item { max-width: 100%; }
|
||||
.ops-b-filter-card .ant-select,
|
||||
.ops-b-filter-card .ant-picker,
|
||||
.ops-b-filter-card .ant-input-affix-wrapper { max-width: 100%; }
|
||||
|
||||
.ops-b-summary-row { margin-top: -4px; }
|
||||
.ops-b-summary-row .ant-col { display: flex; }
|
||||
.ops-b-summary-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 98px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ops-b-summary-card::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: var(--sycm-color-primary);
|
||||
content: '';
|
||||
}
|
||||
.ops-b-summary-card.is-green::before { background: var(--sycm-color-success); }
|
||||
.ops-b-summary-card.is-orange::before { background: var(--sycm-color-orange); }
|
||||
.ops-b-summary-card.is-gold::before { background: var(--sycm-color-warning); }
|
||||
.ops-b-summary-card .ant-card-body {
|
||||
display: flex;
|
||||
min-height: 98px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.ops-b-summary-card .ant-statistic { width: 100%; }
|
||||
.ops-b-summary-card .ant-statistic-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
.ops-b-summary-card .ant-statistic-content {
|
||||
color: var(--sycm-text-strong);
|
||||
font-family: var(--sycm-font-family-number);
|
||||
font-size: var(--sycm-font-size-metric);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.ops-b-panel { margin-bottom: 0; overflow: hidden; }
|
||||
.ops-b-panel .ant-card-head {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom-color: var(--sycm-border-light);
|
||||
}
|
||||
.ops-b-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); }
|
||||
.ops-b-panel .ant-card-body { padding: 0 16px 16px; }
|
||||
.ops-b-panel .ant-table { font-size: var(--sycm-font-size-caption); }
|
||||
.ops-b-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); }
|
||||
.ops-b-panel .ant-table-placeholder .ant-table-cell { height: 180px; }
|
||||
.ops-b-id-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-family: var(--sycm-font-family-number);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
.ops-b-daily-table { margin-top: var(--sycm-space-md); }
|
||||
|
||||
.ops-b-detail-drawer .ant-drawer-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom-color: var(--sycm-border-light);
|
||||
}
|
||||
.ops-b-detail-drawer .ant-drawer-title {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
.ops-b-detail-drawer .ant-drawer-body { padding: 20px; }
|
||||
.ops-b-detail-drawer .ant-descriptions { margin-bottom: var(--sycm-space-md); }
|
||||
|
||||
@container ops-b-incentive (max-width: 900px) {
|
||||
.ops-b-filter-card .ant-space-item { flex: 1 1 180px; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ops-b-header {
|
||||
min-height: 72px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding-block: 12px;
|
||||
}
|
||||
.ops-b-filter-card .ant-select,
|
||||
.ops-b-filter-card .ant-picker,
|
||||
.ops-b-filter-card .ant-input-affix-wrapper { width: 100%; }
|
||||
.ops-b-panel .ant-card-body { padding-inline: 10px; }
|
||||
.ops-b-detail-drawer .ant-drawer-body { padding: 14px; }
|
||||
}
|
||||
@ -1,990 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popover,
|
||||
Segmented,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Table,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
ExclamationCircleOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
getProductAnalysisMeta,
|
||||
getProductAnalysisProducts,
|
||||
getProductAnalysisSignalThresholds,
|
||||
getProductAnalysisTab,
|
||||
saveProductAnalysisSignalThresholds,
|
||||
} from '../../api/productAnalysis';
|
||||
import {
|
||||
SycmBusinessPanel,
|
||||
SycmDashboardMetric,
|
||||
SycmModuleTabs,
|
||||
SycmPage,
|
||||
SycmStatusTag,
|
||||
SycmToolbar,
|
||||
} from '../../components/Common';
|
||||
import {
|
||||
SYCM_CHART_PEER_AVG_COLOR,
|
||||
SYCM_CHART_STORE_COLOR,
|
||||
sycmEchartsBase,
|
||||
sycmLineSeriesDefaults,
|
||||
} from '../../design-system';
|
||||
import './style.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const DEFAULT_TAB = 'refunds';
|
||||
const TAB_ITEMS = [
|
||||
{ key: 'refunds', label: '退款售后' },
|
||||
{ key: 'promotion', label: '推广投放' },
|
||||
{ key: 'activities', label: '活动信息' },
|
||||
{ key: 'navigator', label: '商品领航员' },
|
||||
{ key: 'service-issues', label: '体验问题' },
|
||||
{ key: 'bad-reviews', label: '差评分析' },
|
||||
];
|
||||
const TAB_KEY_SET = new Set(TAB_ITEMS.map((item) => item.key));
|
||||
const RANGE_SHORTCUTS = [
|
||||
{ key: 'realtime', label: '实时' },
|
||||
{ key: 'yesterday', label: '昨日' },
|
||||
{ key: '7d', label: '近7天' },
|
||||
{ key: '30d', label: '近30天' },
|
||||
{ key: 'week', label: '本周' },
|
||||
{ key: 'month', label: '本月' },
|
||||
];
|
||||
const DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS = Object.freeze({
|
||||
ctr: { low: 3, high: 5 },
|
||||
conversion: { low: 0.3, high: 0.5 },
|
||||
});
|
||||
const TRAFFIC_SIGNAL_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'red', label: <span className="product-analysis-signal-filter__label is-red"><i />红灯</span> },
|
||||
{ value: 'yellow', label: <span className="product-analysis-signal-filter__label is-yellow"><i />黄灯</span> },
|
||||
{ value: 'green', label: <span className="product-analysis-signal-filter__label is-green"><i />绿灯</span> },
|
||||
];
|
||||
|
||||
const MONEY_KEYS = new Set(['payOrdrAmt', 'customerUnitPrice', 'pieceUnitPrice', 'spend', 'minPrice', 'maxPrice']);
|
||||
const PERCENT_KEYS = new Set([
|
||||
'goodsVcr',
|
||||
'afterSaleRate',
|
||||
'feeRatio',
|
||||
'ctr',
|
||||
'clickConversionRate',
|
||||
'qltyRfndOrdrCntRto1m',
|
||||
'ptHelpPzRate30d',
|
||||
]);
|
||||
|
||||
const toNumber = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const formatNumber = (value, digits = 0) => new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(toNumber(value));
|
||||
|
||||
const formatCurrency = (value) => `¥${formatNumber(value, 2)}`;
|
||||
const formatPercent = (value) => `${formatNumber(value, 2)}%`;
|
||||
|
||||
const normalizeTrafficSignalThresholds = (value) => {
|
||||
const normalizeMetric = (metric, defaults) => {
|
||||
const low = Number(metric?.low);
|
||||
const high = Number(metric?.high);
|
||||
return {
|
||||
low: Number.isFinite(low) ? low : defaults.low,
|
||||
high: Number.isFinite(high) ? high : defaults.high,
|
||||
};
|
||||
};
|
||||
return {
|
||||
ctr: normalizeMetric(value?.ctr, DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS.ctr),
|
||||
conversion: normalizeMetric(value?.conversion, DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS.conversion),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveTrafficSignal = (value, thresholds) => {
|
||||
const numericValue = toNumber(value);
|
||||
if (numericValue < thresholds.low) {
|
||||
return { tone: 'red', label: '偏低', boundary: thresholds.low };
|
||||
}
|
||||
if (numericValue < thresholds.high) {
|
||||
return { tone: 'yellow', label: '关注', boundary: thresholds.high };
|
||||
}
|
||||
return { tone: 'green', label: '良好', boundary: thresholds.high };
|
||||
};
|
||||
|
||||
const buildTrafficSignalReason = ({ metricKey, metricLabel, value, signal, row }) => {
|
||||
if (signal.tone === 'red') {
|
||||
if (metricKey === 'ctr' && toNumber(row.signalImpressions) <= 0) {
|
||||
return '近30天暂无推广曝光,点击率按 0% 计';
|
||||
}
|
||||
if (metricKey === 'conversion' && toNumber(row.signalClicks) <= 0) {
|
||||
return '近30天暂无推广点击,转化率按 0% 计';
|
||||
}
|
||||
return `近30天${metricLabel} ${formatPercent(value)},低于 ${formatPercent(signal.boundary)}`;
|
||||
}
|
||||
if (signal.tone === 'yellow') {
|
||||
return `近30天${metricLabel} ${formatPercent(value)},尚未达到 ${formatPercent(signal.boundary)}`;
|
||||
}
|
||||
return `近30天${metricLabel} ${formatPercent(value)},已达到 ${formatPercent(signal.boundary)}`;
|
||||
};
|
||||
|
||||
const ProductOperatingSignals = ({ row, thresholds }) => {
|
||||
const metrics = row.signalWindowEndDate && !row.signalIsObserving
|
||||
? [
|
||||
{ key: 'ctr', label: '点击率', value: row.signalCtr, thresholds: thresholds.ctr },
|
||||
{ key: 'conversion', label: '转化率', value: row.signalClickConversionRate, thresholds: thresholds.conversion },
|
||||
].map((metric) => {
|
||||
const signal = resolveTrafficSignal(metric.value, metric.thresholds);
|
||||
return {
|
||||
...metric,
|
||||
signal,
|
||||
reason: buildTrafficSignalReason({
|
||||
metricKey: metric.key,
|
||||
metricLabel: metric.label,
|
||||
value: metric.value,
|
||||
signal,
|
||||
row,
|
||||
}),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
if (!metrics.length && !row.signalIsObserving) {
|
||||
return <span className="product-analysis-signal-unavailable">--</span>;
|
||||
}
|
||||
|
||||
const redReasons = metrics.filter((metric) => metric.signal.tone === 'red');
|
||||
|
||||
return (
|
||||
<div className="product-analysis-signal-cell">
|
||||
{metrics.length ? (
|
||||
<div className="product-analysis-signal-list">
|
||||
{metrics.map((metric) => (
|
||||
<Tooltip key={metric.key} title={metric.reason} placement="top">
|
||||
<span className={`product-analysis-signal is-${metric.signal.tone}`}>
|
||||
<span className="product-analysis-signal__lamp" />
|
||||
<span>{metric.label}</span>
|
||||
<strong>{formatPercent(metric.value)}</strong>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{row.signalIsObserving ? (
|
||||
<div className="product-analysis-signal-observing">
|
||||
<span className="product-analysis-signal-observing__dot" />
|
||||
<span>
|
||||
<strong>新品观察期</strong>
|
||||
<small>点击率、转化率于第 8 天起展示</small>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{redReasons.length ? (
|
||||
<span className="product-analysis-signal-reason" title={redReasons.map((metric) => metric.reason).join(';')}>
|
||||
<ExclamationCircleOutlined />
|
||||
<span>{redReasons.map((metric) => metric.reason).join(';')}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProductQualitySignalCell = ({ row }) => {
|
||||
if (!row.signalQualityStatus) {
|
||||
return <span className="product-analysis-signal-unavailable">--</span>;
|
||||
}
|
||||
return (
|
||||
<div className="product-analysis-signal-cell">
|
||||
<div className="product-analysis-signal-list">
|
||||
<Tooltip title={row.signalQualityReason || '品质退款率同行排名暂无说明'} placement="top">
|
||||
<span className={`product-analysis-signal is-${row.signalQualityStatus}`}>
|
||||
<span className="product-analysis-signal__lamp" />
|
||||
<span>品质退款率</span>
|
||||
<strong>{formatPercent(row.signalQualityRefundRate)}</strong>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrafficSignalSettings = ({ value, onChange, onReset, onSave, saving }) => (
|
||||
<div className="product-analysis-signal-settings">
|
||||
<div className="product-analysis-signal-settings__header">
|
||||
<strong>经营信号阈值</strong>
|
||||
<span>近30天 · 新品观察期7天</span>
|
||||
</div>
|
||||
{[
|
||||
{ key: 'ctr', label: '点击率', step: 0.1 },
|
||||
{ key: 'conversion', label: '转化率', step: 0.1 },
|
||||
].map((metric) => (
|
||||
<div className="product-analysis-signal-setting" key={metric.key}>
|
||||
<strong>{metric.label}</strong>
|
||||
<label>
|
||||
<span className="is-red">红/黄</span>
|
||||
<div className="product-analysis-threshold-input">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
precision={1}
|
||||
step={metric.step}
|
||||
value={value[metric.key].low}
|
||||
onChange={(nextValue) => onChange(metric.key, 'low', nextValue)}
|
||||
/>
|
||||
<span aria-hidden="true">%</span>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span className="is-green">黄/绿</span>
|
||||
<div className="product-analysis-threshold-input">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
precision={1}
|
||||
step={metric.step}
|
||||
value={value[metric.key].high}
|
||||
onChange={(nextValue) => onChange(metric.key, 'high', nextValue)}
|
||||
/>
|
||||
<span aria-hidden="true">%</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div className="product-analysis-quality-rule">
|
||||
<strong>品质退款率</strong>
|
||||
<div>
|
||||
<span className="is-green">超越30% / 50%同行</span>
|
||||
<span className="is-yellow">同行后30%</span>
|
||||
<span className="is-red">同行后10%</span>
|
||||
</div>
|
||||
<small>仅按主营类目排名判定</small>
|
||||
</div>
|
||||
<div className="product-analysis-signal-settings__footer">
|
||||
<Button type="text" size="small" onClick={onReset}>恢复默认</Button>
|
||||
<Button type="primary" size="small" onClick={onSave} loading={saving}>保存设置</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const formatTableValue = (key, value) => {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
if (MONEY_KEYS.has(key)) return formatCurrency(value);
|
||||
if (PERCENT_KEYS.has(key)) return formatPercent(value);
|
||||
if (key === 'statDate') return value;
|
||||
if (typeof value === 'number' || /^-?\d+(\.\d+)?$/.test(String(value))) return formatNumber(value, 0);
|
||||
return value;
|
||||
};
|
||||
|
||||
const formatTabMetric = (metric) => {
|
||||
if (metric.unit === '¥') return formatCurrency(metric.value);
|
||||
if (metric.unit === '%') return formatPercent(metric.value);
|
||||
return formatNumber(metric.value, 0);
|
||||
};
|
||||
|
||||
const buildShortcutRange = (shortcut, latestDate) => {
|
||||
const end = dayjs(latestDate);
|
||||
if (!end.isValid()) return ['', ''];
|
||||
if (shortcut === 'yesterday') {
|
||||
const yesterday = end.subtract(1, 'day').format('YYYY-MM-DD');
|
||||
return [yesterday, yesterday];
|
||||
}
|
||||
if (shortcut === '7d') return [end.subtract(6, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||
if (shortcut === '30d') return [end.subtract(29, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||
if (shortcut === 'week') {
|
||||
const mondayOffset = end.day() === 0 ? 6 : end.day() - 1;
|
||||
return [end.subtract(mondayOffset, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||
}
|
||||
if (shortcut === 'month') return [end.startOf('month').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||
return [end.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||
};
|
||||
|
||||
const ProductAnalysis = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialTab = TAB_KEY_SET.has(searchParams.get('tab')) ? searchParams.get('tab') : DEFAULT_TAB;
|
||||
const requestedProductId = searchParams.get('productId') || '';
|
||||
const [meta, setMeta] = useState({ latestDate: '', dates: [], shops: [] });
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
shopName: '',
|
||||
ider: '',
|
||||
listingRange: null,
|
||||
keyword: '',
|
||||
onlyActive: false,
|
||||
});
|
||||
const [rangeShortcut, setRangeShortcut] = useState('7d');
|
||||
const [products, setProducts] = useState([]);
|
||||
const [metrics, setMetrics] = useState({});
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
||||
const [sorter, setSorter] = useState({ field: 'payOrdrAmt', order: 'descend' });
|
||||
const [productsLoading, setProductsLoading] = useState(true);
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [tabData, setTabData] = useState(null);
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [serviceIssueDetail, setServiceIssueDetail] = useState(null);
|
||||
const [signalThresholds, setSignalThresholds] = useState(() => normalizeTrafficSignalThresholds(null));
|
||||
const [signalThresholdDraft, setSignalThresholdDraft] = useState(() => normalizeTrafficSignalThresholds(null));
|
||||
const [signalStatus, setSignalStatus] = useState('all');
|
||||
const [signalSettingsOpen, setSignalSettingsOpen] = useState(false);
|
||||
const [signalSettingsSaving, setSignalSettingsSaving] = useState(false);
|
||||
const tabCacheRef = useRef(new Map());
|
||||
const productsRequestRef = useRef(0);
|
||||
|
||||
const loadProducts = async (options = {}) => {
|
||||
const requestId = productsRequestRef.current + 1;
|
||||
productsRequestRef.current = requestId;
|
||||
const nextFilters = options.filters || filters;
|
||||
const nextPage = options.page || pagination.current;
|
||||
const nextPageSize = options.pageSize || pagination.pageSize;
|
||||
const nextSorter = options.sorter || sorter;
|
||||
const nextSignalStatus = options.signalStatus || signalStatus;
|
||||
|
||||
setProductsLoading(true);
|
||||
try {
|
||||
const response = await getProductAnalysisProducts({
|
||||
page: nextPage,
|
||||
pageSize: nextPageSize,
|
||||
startDate: nextFilters.startDate || undefined,
|
||||
endDate: nextFilters.endDate || undefined,
|
||||
shopName: nextFilters.shopName || undefined,
|
||||
ider: nextFilters.ider || undefined,
|
||||
listingFrom: nextFilters.listingRange?.[0] || undefined,
|
||||
listingTo: nextFilters.listingRange?.[1] || undefined,
|
||||
keyword: nextFilters.keyword || undefined,
|
||||
onlyActive: nextFilters.onlyActive || undefined,
|
||||
signalStatus: nextSignalStatus === 'all' ? undefined : nextSignalStatus,
|
||||
sortField: nextSorter.field,
|
||||
sortOrder: nextSorter.order,
|
||||
rangeDays: options.rangeDays,
|
||||
});
|
||||
if (requestId !== productsRequestRef.current) return;
|
||||
|
||||
const data = response.data || {};
|
||||
const nextRows = data.rows || [];
|
||||
setProducts(nextRows);
|
||||
setMetrics(data.metrics || {});
|
||||
setPagination({ current: data.page || nextPage, pageSize: data.pageSize || nextPageSize, total: data.total || 0 });
|
||||
|
||||
setSelectedProduct((current) => {
|
||||
const preferredId = current?.goodsId || requestedProductId;
|
||||
return nextRows.find((row) => String(row.goodsId) === String(preferredId)) || nextRows[0] || null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestId !== productsRequestRef.current) return;
|
||||
setProducts([]);
|
||||
setMetrics({});
|
||||
setPagination((current) => ({ ...current, total: 0 }));
|
||||
setSelectedProduct(null);
|
||||
message.error(error.response?.data?.error || error.message || '商品数据加载失败');
|
||||
} finally {
|
||||
if (requestId === productsRequestRef.current) setProductsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const bootstrap = async () => {
|
||||
const productsPromise = loadProducts({ filters, page: 1, rangeDays: 7 });
|
||||
try {
|
||||
const [response, thresholdResult] = await Promise.all([
|
||||
getProductAnalysisMeta(),
|
||||
getProductAnalysisSignalThresholds()
|
||||
.then((thresholdResponse) => ({ response: thresholdResponse, error: null }))
|
||||
.catch((error) => ({ response: null, error })),
|
||||
]);
|
||||
if (!active) return;
|
||||
const nextMeta = response.data || {};
|
||||
const [startDate, endDate] = buildShortcutRange('7d', nextMeta.latestDate);
|
||||
const nextFilters = { ...filters, startDate, endDate };
|
||||
setMeta(nextMeta);
|
||||
setFilters(nextFilters);
|
||||
if (thresholdResult.response) {
|
||||
const nextThresholds = normalizeTrafficSignalThresholds(
|
||||
thresholdResult.response.data?.thresholds
|
||||
);
|
||||
setSignalThresholds(nextThresholds);
|
||||
setSignalThresholdDraft(nextThresholds);
|
||||
} else {
|
||||
message.warning('个人经营信号阈值读取失败,当前使用系统默认值');
|
||||
}
|
||||
await productsPromise;
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
setProductsLoading(false);
|
||||
message.error(error.response?.data?.error || error.message || '商品分析初始化失败');
|
||||
}
|
||||
};
|
||||
bootstrap();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// Initial bootstrap intentionally owns the first product query.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setServiceIssueDetail(null);
|
||||
if (!selectedProduct?.goodsId) {
|
||||
setTabData(null);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = `${selectedProduct.goodsId}:${filters.startDate}:${filters.endDate}:${activeTab}`;
|
||||
const cached = tabCacheRef.current.get(cacheKey);
|
||||
if (cached) {
|
||||
setTabData(cached);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
setTabLoading(true);
|
||||
setTabData(null);
|
||||
getProductAnalysisTab(selectedProduct.goodsId, activeTab, {
|
||||
startDate: filters.startDate,
|
||||
endDate: filters.endDate,
|
||||
})
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const data = response.data || null;
|
||||
tabCacheRef.current.set(cacheKey, data);
|
||||
setTabData(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
setTabData({ status: 'error', message: error.response?.data?.error || error.message || '子模块加载失败' });
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setTabLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [activeTab, filters.endDate, filters.startDate, selectedProduct]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
if (selectedProduct?.goodsId) nextParams.set('productId', selectedProduct.goodsId);
|
||||
else nextParams.delete('productId');
|
||||
nextParams.set('tab', activeTab);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
// searchParams is deliberately excluded to avoid URL update loops.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, selectedProduct?.goodsId, setSearchParams]);
|
||||
|
||||
const dashboardMetrics = useMemo(() => [
|
||||
{ key: 'total', label: '商品数', value: formatNumber(metrics.total), footerLabel: '有销售', footerValue: formatNumber(metrics.activeGoods) },
|
||||
{ key: 'payOrdrAmt', label: '销售额', value: formatCurrency(metrics.payOrdrAmt) },
|
||||
{ key: 'orderNum', label: '订单量', value: formatNumber(metrics.orderNum) },
|
||||
{ key: 'payOrdrUsrCnt', label: '买家数', value: formatNumber(metrics.payOrdrUsrCnt) },
|
||||
{ key: 'goodsUv', label: '访客数', value: formatNumber(metrics.goodsUv) },
|
||||
{ key: 'goodsVcr', label: '平均成交转化率', value: formatPercent(metrics.goodsVcr) },
|
||||
], [metrics]);
|
||||
|
||||
const productColumns = useMemo(() => [
|
||||
{
|
||||
title: '商品',
|
||||
key: 'product',
|
||||
fixed: 'left',
|
||||
width: 310,
|
||||
render: (_, row) => (
|
||||
<div className="product-analysis-product-cell">
|
||||
<Image width={46} height={46} src={row.hdThumbUrl} preview={false} fallback="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" />
|
||||
<div>
|
||||
<strong title={row.goodsName}>{row.goodsName || '--'}</strong>
|
||||
<span>ID {row.goodsId} · {row.shopName || '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: 'SPU', dataIndex: 'spu', key: 'spu', width: 120, ellipsis: true },
|
||||
{ title: 'SKU货号', dataIndex: 'skuCode', key: 'skuCode', width: 130, ellipsis: true },
|
||||
{ title: '负责人', dataIndex: 'idOwner', key: 'idOwner', width: 90 },
|
||||
{ title: '上架时间', dataIndex: 'firstCreatedAt', key: 'firstCreatedAt', width: 105, render: (value) => value || '--' },
|
||||
{
|
||||
title: '近30天推广信号',
|
||||
key: 'trafficSignal',
|
||||
width: 300,
|
||||
render: (_, row) => <ProductOperatingSignals row={row} thresholds={signalThresholds} />,
|
||||
},
|
||||
{
|
||||
title: '近30天品质信号(商品领航员)',
|
||||
key: 'qualitySignal',
|
||||
width: 190,
|
||||
render: (_, row) => <ProductQualitySignalCell row={row} />,
|
||||
},
|
||||
{ title: '访客数', dataIndex: 'goodsUv', key: 'goodsUv', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) },
|
||||
{ title: '销售额', dataIndex: 'payOrdrAmt', key: 'payOrdrAmt', width: 120, align: 'right', sorter: true, render: formatCurrency },
|
||||
{ title: '订单量', dataIndex: 'orderNum', key: 'orderNum', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) },
|
||||
{ title: '买家数', dataIndex: 'payOrdrUsrCnt', key: 'payOrdrUsrCnt', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) },
|
||||
{ title: '成交件数', dataIndex: 'payOrdrGoodsQty', key: 'payOrdrGoodsQty', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) },
|
||||
{ title: '成交转化率', dataIndex: 'goodsVcr', key: 'goodsVcr', width: 120, align: 'right', sorter: true, render: formatPercent },
|
||||
{ title: '客单价', dataIndex: 'customerUnitPrice', key: 'customerUnitPrice', width: 110, align: 'right', sorter: true, render: formatCurrency },
|
||||
{
|
||||
title: '价格状态',
|
||||
dataIndex: 'isPriceBreak',
|
||||
key: 'isPriceBreak',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (value) => <SycmStatusTag tone={Number(value) === 1 ? 'error' : 'success'}>{Number(value) === 1 ? '破价' : '正常'}</SycmStatusTag>,
|
||||
},
|
||||
], [signalThresholds]);
|
||||
|
||||
// 快照表缺日期时前后端都不补点会导致 x 轴日期跳跃,这里按日历补全缺失日期
|
||||
const tabTrend = useMemo(() => {
|
||||
const trend = tabData?.trend || [];
|
||||
if (trend.length < 2) return trend;
|
||||
const byDate = new Map(trend.map((item) => [String(item.date).slice(0, 10), item]));
|
||||
const dates = [...byDate.keys()].sort();
|
||||
const start = dayjs(dates[0]);
|
||||
const end = dayjs(dates[dates.length - 1]);
|
||||
if (!start.isValid() || !end.isValid() || end.diff(start, 'day') + 1 === trend.length) return trend;
|
||||
const totalDays = end.diff(start, 'day') + 1;
|
||||
if (totalDays > 400) return trend;
|
||||
const filled = [];
|
||||
for (let cursor = start; !cursor.isAfter(end, 'day'); cursor = cursor.add(1, 'day')) {
|
||||
const key = cursor.format('YYYY-MM-DD');
|
||||
filled.push(byDate.get(key) || { date: key, primary: null, secondary: null });
|
||||
}
|
||||
return filled;
|
||||
}, [tabData]);
|
||||
|
||||
const tabChartOption = useMemo(() => {
|
||||
const trend = tabTrend;
|
||||
return {
|
||||
...sycmEchartsBase,
|
||||
grid: { ...sycmEchartsBase.grid, top: 48, right: 54, bottom: 28, left: 52 },
|
||||
legend: { ...sycmEchartsBase.legend, data: [tabData?.trendLabels?.primary, tabData?.trendLabels?.secondary].filter(Boolean) },
|
||||
xAxis: {
|
||||
...sycmEchartsBase.xAxis,
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: trend.map((item) => item.date),
|
||||
},
|
||||
yAxis: [
|
||||
{ ...sycmEchartsBase.yAxis, type: 'value' },
|
||||
{ ...sycmEchartsBase.yAxis, type: 'value', splitLine: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
...sycmLineSeriesDefaults,
|
||||
name: tabData?.trendLabels?.primary,
|
||||
data: trend.map((item) => (item.primary === null ? null : toNumber(item.primary))),
|
||||
connectNulls: true,
|
||||
lineStyle: { width: 3, color: SYCM_CHART_STORE_COLOR },
|
||||
itemStyle: { color: SYCM_CHART_STORE_COLOR },
|
||||
},
|
||||
{
|
||||
...sycmLineSeriesDefaults,
|
||||
name: tabData?.trendLabels?.secondary,
|
||||
yAxisIndex: 1,
|
||||
data: trend.map((item) => (item.secondary === null ? null : toNumber(item.secondary))),
|
||||
connectNulls: true,
|
||||
lineStyle: { width: 3, color: SYCM_CHART_PEER_AVG_COLOR },
|
||||
itemStyle: { color: SYCM_CHART_PEER_AVG_COLOR },
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [tabTrend, tabData]);
|
||||
|
||||
const tabColumns = useMemo(() => (tabData?.columns || []).map((column) => ({
|
||||
title: column.title,
|
||||
dataIndex: column.key,
|
||||
key: column.key,
|
||||
align: column.align || (column.key === 'statDate' ? 'left' : 'right'),
|
||||
render: (value) => (column.key === 'viewOrders'
|
||||
? <span className="product-analysis-view-orders"><RightOutlined />查看订单</span>
|
||||
: formatTableValue(column.key, value)),
|
||||
})), [tabData]);
|
||||
|
||||
const serviceIssueOrderColumns = useMemo(() => [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderSn',
|
||||
key: 'orderSn',
|
||||
width: 210,
|
||||
render: (value) => <Text copyable={{ text: value }}>{value || '--'}</Text>,
|
||||
},
|
||||
{
|
||||
title: '问题详情',
|
||||
dataIndex: 'problemDetail',
|
||||
key: 'problemDetail',
|
||||
width: 220,
|
||||
render: (value) => <span className="product-analysis-order-detail">{value || '--'}</span>,
|
||||
},
|
||||
{
|
||||
title: '问题分类',
|
||||
dataIndex: 'categoryDescription',
|
||||
key: 'categoryDescription',
|
||||
width: 200,
|
||||
render: (value) => <span className="product-analysis-order-detail">{value || '--'}</span>,
|
||||
},
|
||||
{ title: '求助时间', dataIndex: 'seekHelpTime', key: 'seekHelpTime', width: 170 },
|
||||
{
|
||||
title: '体验状态',
|
||||
dataIndex: 'isNegative',
|
||||
key: 'isNegative',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (value) => (
|
||||
<SycmStatusTag tone={value ? 'error' : 'default'}>{value ? '负向体验' : '一般问题'}</SycmStatusTag>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
const openServiceIssueOrders = (row) => {
|
||||
if (tabData?.tabKey !== 'service-issues') return;
|
||||
const orders = (tabData.orderDetails || []).filter((item) => item.issueType === row.issueType);
|
||||
setServiceIssueDetail({ issueType: row.issueType, orders });
|
||||
};
|
||||
|
||||
const handleQuery = () => {
|
||||
tabCacheRef.current.clear();
|
||||
loadProducts({ page: 1 });
|
||||
};
|
||||
|
||||
const handleSignalStatusChange = (nextStatus) => {
|
||||
setSignalStatus(nextStatus);
|
||||
loadProducts({ page: 1, signalStatus: nextStatus });
|
||||
};
|
||||
|
||||
const handleTableChange = (nextPagination, _tableFilters, tableSorter) => {
|
||||
const nextSorter = tableSorter?.field
|
||||
? { field: tableSorter.field, order: tableSorter.order || 'descend' }
|
||||
: sorter;
|
||||
setSorter(nextSorter);
|
||||
loadProducts({ page: nextPagination.current, pageSize: nextPagination.pageSize, sorter: nextSorter });
|
||||
};
|
||||
|
||||
const updateSignalThresholdDraft = (metricKey, boundaryKey, value) => {
|
||||
setSignalThresholdDraft((current) => ({
|
||||
...current,
|
||||
[metricKey]: {
|
||||
...current[metricKey],
|
||||
[boundaryKey]: Number.isFinite(Number(value)) ? Number(value) : 0,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const resetSignalThresholdDraft = () => {
|
||||
setSignalThresholdDraft(normalizeTrafficSignalThresholds(null));
|
||||
};
|
||||
|
||||
const saveSignalThresholds = async () => {
|
||||
const nextThresholds = normalizeTrafficSignalThresholds(signalThresholdDraft);
|
||||
if (nextThresholds.ctr.low >= nextThresholds.ctr.high
|
||||
|| nextThresholds.conversion.low >= nextThresholds.conversion.high) {
|
||||
message.warning('黄绿分界值必须大于红黄分界值');
|
||||
return;
|
||||
}
|
||||
setSignalSettingsSaving(true);
|
||||
try {
|
||||
const response = await saveProductAnalysisSignalThresholds(nextThresholds);
|
||||
const savedThresholds = normalizeTrafficSignalThresholds(response.data?.thresholds || nextThresholds);
|
||||
setSignalThresholds(savedThresholds);
|
||||
setSignalThresholdDraft(savedThresholds);
|
||||
setSignalSettingsOpen(false);
|
||||
if (signalStatus !== 'all') {
|
||||
await loadProducts({ page: 1, signalStatus });
|
||||
}
|
||||
message.success('个人经营信号阈值已保存');
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || error.message || '个人经营信号阈值保存失败');
|
||||
} finally {
|
||||
setSignalSettingsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SycmPage className="product-analysis-page">
|
||||
<SycmBusinessPanel className="product-analysis-filter-panel">
|
||||
<SycmToolbar>
|
||||
<div className="product-analysis-filters">
|
||||
<Select value="pdd" disabled options={[{ value: 'pdd', label: '拼多多' }]} aria-label="平台" />
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
value={filters.shopName || undefined}
|
||||
placeholder="全部店铺"
|
||||
options={(meta.shops || []).map((shop) => ({ value: shop, label: shop }))}
|
||||
onChange={(value) => setFilters((current) => ({ ...current, shopName: value || '' }))}
|
||||
aria-label="店铺"
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
value={filters.ider || undefined}
|
||||
placeholder="全部负责人"
|
||||
options={(meta.owners || []).map((owner) => ({ value: owner, label: owner }))}
|
||||
onChange={(value) => setFilters((current) => ({ ...current, ider: value || '' }))}
|
||||
aria-label="负责人"
|
||||
/>
|
||||
<div className="product-analysis-range-shortcuts" role="group" aria-label="时间范围快捷选择">
|
||||
{RANGE_SHORTCUTS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={rangeShortcut === item.key ? 'is-active' : ''}
|
||||
onClick={() => {
|
||||
const [startDate, endDate] = buildShortcutRange(item.key, meta.latestDate);
|
||||
setRangeShortcut(item.key);
|
||||
setFilters((current) => ({ ...current, startDate, endDate }));
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<RangePicker
|
||||
value={filters.startDate && filters.endDate ? [dayjs(filters.startDate), dayjs(filters.endDate)] : null}
|
||||
format="YYYY-MM-DD"
|
||||
allowClear={false}
|
||||
disabledDate={(date) => (
|
||||
(meta.minDate && date.isBefore(dayjs(meta.minDate), 'day'))
|
||||
|| (meta.latestDate && date.isAfter(dayjs(meta.latestDate), 'day'))
|
||||
)}
|
||||
onChange={(dates) => {
|
||||
if (!dates?.[0] || !dates?.[1]) return;
|
||||
setRangeShortcut('custom');
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
startDate: dates[0].format('YYYY-MM-DD'),
|
||||
endDate: dates[1].format('YYYY-MM-DD'),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<RangePicker
|
||||
value={filters.listingRange?.length === 2 ? [dayjs(filters.listingRange[0]), dayjs(filters.listingRange[1])] : null}
|
||||
format="YYYY-MM-DD"
|
||||
allowClear
|
||||
placeholder={['上架开始', '上架结束']}
|
||||
onChange={(dates) => setFilters((current) => ({
|
||||
...current,
|
||||
listingRange: dates?.length === 2 ? [dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')] : null,
|
||||
}))}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
value={filters.keyword}
|
||||
placeholder="搜索商品ID / SKU / SPU / 商品名"
|
||||
onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))}
|
||||
onPressEnter={handleQuery}
|
||||
/>
|
||||
<Checkbox
|
||||
className="product-analysis-active-filter"
|
||||
checked={filters.onlyActive}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, onlyActive: event.target.checked }))}
|
||||
>
|
||||
仅看有销售/投放
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => loadProducts()} loading={productsLoading}>刷新</Button>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleQuery} loading={productsLoading}>查询</Button>
|
||||
</Space>
|
||||
</SycmToolbar>
|
||||
</SycmBusinessPanel>
|
||||
|
||||
<SycmBusinessPanel
|
||||
title="综合看板"
|
||||
extra={<Text type="secondary">数据区间 {metrics.startDate || '--'} 至 {metrics.endDate || '--'}</Text>}
|
||||
className="product-analysis-overview-panel"
|
||||
>
|
||||
<div className="product-analysis-metrics">
|
||||
{dashboardMetrics.map(({ key, ...item }) => <SycmDashboardMetric key={key} {...item} help={false} />)}
|
||||
</div>
|
||||
</SycmBusinessPanel>
|
||||
|
||||
<SycmBusinessPanel
|
||||
title="商品(款)"
|
||||
extra={(
|
||||
<Space size={10}>
|
||||
<Segmented
|
||||
className="product-analysis-signal-filter"
|
||||
size="small"
|
||||
options={TRAFFIC_SIGNAL_FILTER_OPTIONS}
|
||||
value={signalStatus}
|
||||
onChange={handleSignalStatusChange}
|
||||
aria-label="经营信号筛选"
|
||||
/>
|
||||
<Text type="secondary">共 {formatNumber(pagination.total)} 款,点击行查看单品模块</Text>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={signalSettingsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSignalSettingsOpen(open);
|
||||
if (open) setSignalThresholdDraft(signalThresholds);
|
||||
}}
|
||||
content={(
|
||||
<TrafficSignalSettings
|
||||
value={signalThresholdDraft}
|
||||
onChange={updateSignalThresholdDraft}
|
||||
onReset={resetSignalThresholdDraft}
|
||||
onSave={saveSignalThresholds}
|
||||
saving={signalSettingsSaving}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Button size="small" icon={<SettingOutlined />}>信号阈值</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
)}
|
||||
className="product-analysis-table-panel"
|
||||
>
|
||||
<Table
|
||||
rowKey="goodsId"
|
||||
size="small"
|
||||
loading={productsLoading}
|
||||
columns={productColumns}
|
||||
dataSource={products}
|
||||
scroll={{ x: 1890 }}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (total) => `共 ${formatNumber(total)} 款`,
|
||||
}}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedProduct?.goodsId ? [selectedProduct.goodsId] : [],
|
||||
onSelect: (record) => setSelectedProduct(record),
|
||||
columnWidth: 42,
|
||||
}}
|
||||
rowClassName={(record) => String(record.goodsId) === String(selectedProduct?.goodsId) ? 'is-selected' : ''}
|
||||
onRow={(record) => ({
|
||||
onClick: () => setSelectedProduct(record),
|
||||
})}
|
||||
onChange={handleTableChange}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前筛选条件暂无商品" /> }}
|
||||
/>
|
||||
</SycmBusinessPanel>
|
||||
|
||||
<SycmBusinessPanel
|
||||
title={selectedProduct ? (
|
||||
<span className="product-analysis-selected-product">
|
||||
<Image
|
||||
width={40}
|
||||
height={40}
|
||||
src={selectedProduct.hdThumbUrl}
|
||||
preview={false}
|
||||
fallback="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
|
||||
/>
|
||||
<span className="product-analysis-selected-product__copy">
|
||||
<strong title={selectedProduct.goodsName}>{selectedProduct.goodsName || '未命名商品'}</strong>
|
||||
<span>{selectedProduct.shopName} · 商品ID {selectedProduct.goodsId} · SKU {selectedProduct.skuCode || '--'}</span>
|
||||
</span>
|
||||
</span>
|
||||
) : '单品业务模块'}
|
||||
extra={selectedProduct ? <SycmStatusTag tone="info">{selectedProduct.idOwner || '未分配负责人'}</SycmStatusTag> : null}
|
||||
className="product-analysis-detail-panel"
|
||||
>
|
||||
{selectedProduct ? (
|
||||
<>
|
||||
<SycmModuleTabs items={TAB_ITEMS} activeKey={activeTab} onChange={setActiveTab} />
|
||||
<div className="product-analysis-tab-content">
|
||||
{tabLoading ? <Skeleton active paragraph={{ rows: 8 }} /> : null}
|
||||
{!tabLoading && tabData?.status === 'empty' ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={tabData.message} />
|
||||
) : null}
|
||||
{!tabLoading && tabData?.status === 'error' ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={tabData.message || '子模块加载失败'} />
|
||||
) : null}
|
||||
{!tabLoading && tabData?.status === 'ready' ? (
|
||||
<>
|
||||
<div className="product-analysis-tab-metrics">
|
||||
{(tabData.metrics || []).map((item) => (
|
||||
<SycmDashboardMetric key={item.key} label={item.label} value={formatTabMetric(item)} help={false} />
|
||||
))}
|
||||
</div>
|
||||
<div className={`product-analysis-tab-grid${tabData.trend?.length ? '' : ' is-table-only'}`}>
|
||||
{tabData.trend?.length ? (
|
||||
<section className="product-analysis-trend-block">
|
||||
<h3>{tabData.trendTitle || '区间趋势'}</h3>
|
||||
<ReactECharts option={tabChartOption} style={{ height: 286 }} notMerge lazyUpdate />
|
||||
</section>
|
||||
) : null}
|
||||
<section className="product-analysis-history-block">
|
||||
<h3>{tabData.tableTitle || '历史明细'}</h3>
|
||||
<Table
|
||||
rowKey={(row) => row.id || row.statDate}
|
||||
size="small"
|
||||
columns={tabColumns}
|
||||
dataSource={tabData.rows || []}
|
||||
pagination={false}
|
||||
scroll={{ y: 238 }}
|
||||
rowClassName={() => (tabData.tabKey === 'service-issues' ? 'product-analysis-issue-row' : '')}
|
||||
onRow={(row) => (tabData.tabKey === 'service-issues' ? {
|
||||
onClick: () => openServiceIssueOrders(row),
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openServiceIssueOrders(row);
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-label': `查看${row.issueType}的具体订单`,
|
||||
} : {})}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请先从商品主表选择一个商品" />
|
||||
)}
|
||||
</SycmBusinessPanel>
|
||||
<Drawer
|
||||
title={serviceIssueDetail
|
||||
? `${serviceIssueDetail.issueType} · ${serviceIssueDetail.orders.length} 笔订单`
|
||||
: '问题订单明细'}
|
||||
width={980}
|
||||
open={Boolean(serviceIssueDetail)}
|
||||
onClose={() => setServiceIssueDetail(null)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Table
|
||||
rowKey={(row) => row.id}
|
||||
size="small"
|
||||
columns={serviceIssueOrderColumns}
|
||||
dataSource={serviceIssueDetail?.orders || []}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
scroll={{ x: 910 }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无对应订单" /> }}
|
||||
/>
|
||||
</Drawer>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductAnalysis;
|
||||
@ -1,776 +0,0 @@
|
||||
.product-analysis-page {
|
||||
container-name: product-analysis;
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
}
|
||||
|
||||
.product-analysis-filter-panel .sycm-business-panel__body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.product-analysis-filter-panel .sycm-toolbar {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center !important;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-analysis-filter-panel .sycm-toolbar > .ant-space {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.product-analysis-filters {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1 1 100%;
|
||||
grid-template-columns: 96px minmax(170px, 210px) max-content 236px minmax(220px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-analysis-range-shortcuts {
|
||||
display: inline-flex;
|
||||
height: var(--sycm-control-height);
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sycm-border-base);
|
||||
border-radius: var(--sycm-radius-md);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.product-analysis-range-shortcuts button {
|
||||
min-width: 46px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--sycm-border-light);
|
||||
background: transparent;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
line-height: 30px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color var(--sycm-interaction-duration-fast), background var(--sycm-interaction-duration-fast);
|
||||
}
|
||||
|
||||
.product-analysis-range-shortcuts button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.product-analysis-range-shortcuts button:hover {
|
||||
color: var(--sycm-color-primary);
|
||||
background: var(--sycm-color-primary-bg);
|
||||
}
|
||||
|
||||
.product-analysis-range-shortcuts button.is-active {
|
||||
background: var(--sycm-bg-container);
|
||||
color: var(--sycm-color-primary);
|
||||
box-shadow: inset 0 -2px 0 var(--sycm-color-primary);
|
||||
}
|
||||
|
||||
.product-analysis-filters .ant-select,
|
||||
.product-analysis-filters .ant-input-affix-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-analysis-filters > .ant-input-affix-wrapper {
|
||||
height: var(--sycm-control-height);
|
||||
min-height: var(--sycm-control-height);
|
||||
align-items: center;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-analysis-filters > .ant-input-affix-wrapper .ant-input {
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.product-analysis-filters .ant-checkbox-wrapper {
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-analysis-filters .product-analysis-active-filter {
|
||||
display: inline-flex;
|
||||
height: var(--sycm-control-height);
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-md);
|
||||
background: var(--sycm-bg-subtle);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.product-analysis-filters .product-analysis-active-filter .ant-checkbox {
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.product-analysis-filters .product-analysis-active-filter > span:last-child {
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding-inline-start: 6px;
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.product-analysis-filters .product-analysis-active-filter:hover {
|
||||
border-color: var(--sycm-color-primary-border);
|
||||
background: var(--sycm-color-primary-bg);
|
||||
}
|
||||
|
||||
.product-analysis-metrics,
|
||||
.product-analysis-tab-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel > .sycm-business-panel__body {
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .product-analysis-metrics {
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 var(--sycm-radius-xl) var(--sycm-radius-xl);
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .sycm-dashboard-metric {
|
||||
min-height: 98px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--sycm-border-light);
|
||||
border-radius: 0;
|
||||
padding: 14px 16px 13px;
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .sycm-dashboard-metric:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .sycm-dashboard-metric__label {
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-regular);
|
||||
}
|
||||
|
||||
.product-analysis-metrics .sycm-dashboard-metric,
|
||||
.product-analysis-tab-metrics .sycm-dashboard-metric {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.product-analysis-table-panel > .sycm-business-panel__body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.product-analysis-table-panel .ant-table-wrapper {
|
||||
border-top: 1px solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.product-analysis-table-panel .ant-table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-analysis-table-panel .ant-table-row.is-selected > td {
|
||||
background: var(--sycm-interaction-selected-bg) !important;
|
||||
}
|
||||
|
||||
.product-analysis-table-panel .ant-table-row.is-selected > td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--sycm-color-primary);
|
||||
}
|
||||
|
||||
.product-analysis-product-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-analysis-product-cell .ant-image,
|
||||
.product-analysis-product-cell img {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
background: var(--sycm-bg-subtle);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-analysis-product-cell > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.product-analysis-product-cell strong {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-analysis-product-cell span {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-analysis-signal-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-analysis-signal {
|
||||
display: inline-grid;
|
||||
height: 28px;
|
||||
min-width: 118px;
|
||||
grid-template-columns: 9px auto 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--sycm-radius-md);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-analysis-signal strong {
|
||||
justify-self: end;
|
||||
color: currentColor;
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.product-analysis-signal__lamp {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
.product-analysis-signal.is-red {
|
||||
border-color: var(--sycm-color-danger-border);
|
||||
background: var(--sycm-color-danger-bg);
|
||||
color: var(--sycm-color-danger);
|
||||
}
|
||||
|
||||
.product-analysis-signal.is-yellow {
|
||||
border-color: var(--sycm-color-warning-border);
|
||||
background: var(--sycm-color-warning-bg);
|
||||
color: var(--sycm-color-warning);
|
||||
}
|
||||
|
||||
.product-analysis-signal.is-green {
|
||||
border-color: var(--sycm-color-success-border);
|
||||
background: var(--sycm-color-success-bg);
|
||||
color: var(--sycm-color-success);
|
||||
}
|
||||
|
||||
.product-analysis-signal-reason {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
color: var(--sycm-color-danger);
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-reason .anticon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-reason > span:last-child {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.product-analysis-signal-observing {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
min-width: 190px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--sycm-color-primary-border);
|
||||
border-radius: var(--sycm-radius-md);
|
||||
background: var(--sycm-color-primary-bg);
|
||||
color: var(--sycm-color-primary);
|
||||
}
|
||||
|
||||
.product-analysis-signal-observing__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--sycm-color-primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--sycm-color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.product-analysis-signal-observing > span:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-observing strong {
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-observing small {
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-unavailable {
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.product-analysis-signal-filter {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.product-analysis-signal-filter .ant-segmented-item-label {
|
||||
min-width: 48px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-filter__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-analysis-signal-filter__label i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.product-analysis-signal-filter__label.is-red i {
|
||||
background: var(--sycm-color-danger);
|
||||
box-shadow: 0 0 0 3px var(--sycm-color-danger-bg);
|
||||
}
|
||||
|
||||
.product-analysis-signal-filter__label.is-yellow i {
|
||||
background: var(--sycm-color-warning);
|
||||
box-shadow: 0 0 0 3px var(--sycm-color-warning-bg);
|
||||
}
|
||||
|
||||
.product-analysis-signal-filter__label.is-green i {
|
||||
background: var(--sycm-color-success);
|
||||
box-shadow: 0 0 0 3px var(--sycm-color-success-bg);
|
||||
}
|
||||
|
||||
.product-analysis-signal-settings {
|
||||
width: 390px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-settings__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 2px 12px;
|
||||
border-bottom: 1px solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.product-analysis-signal-settings__header strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-body);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.product-analysis-signal-settings__header span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-setting {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
padding: 13px 2px;
|
||||
border-bottom: 1px solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.product-analysis-signal-setting > strong {
|
||||
align-self: center;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.product-analysis-signal-setting label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-setting label > span {
|
||||
font-size: 11px;
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
}
|
||||
|
||||
.product-analysis-signal-setting label > span.is-red {
|
||||
color: var(--sycm-color-danger);
|
||||
}
|
||||
|
||||
.product-analysis-signal-setting label > span.is-green {
|
||||
color: var(--sycm-color-success);
|
||||
}
|
||||
|
||||
.product-analysis-signal-setting .ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-analysis-threshold-input {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) 28px;
|
||||
}
|
||||
|
||||
.product-analysis-threshold-input .ant-input-number {
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.product-analysis-threshold-input > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--sycm-border-base);
|
||||
border-left: 0;
|
||||
border-start-end-radius: var(--sycm-radius-sm);
|
||||
border-end-end-radius: var(--sycm-radius-sm);
|
||||
background: var(--sycm-bg-subtle);
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.product-analysis-quality-rule {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 8px 10px;
|
||||
align-items: center;
|
||||
padding: 13px 2px;
|
||||
border-bottom: 1px solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.product-analysis-quality-rule > strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.product-analysis-quality-rule > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-analysis-quality-rule span {
|
||||
padding: 3px 6px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.product-analysis-quality-rule .is-green {
|
||||
color: var(--sycm-color-success);
|
||||
}
|
||||
|
||||
.product-analysis-quality-rule .is-yellow {
|
||||
color: var(--sycm-color-warning);
|
||||
}
|
||||
|
||||
.product-analysis-quality-rule .is-red {
|
||||
color: var(--sycm-color-danger);
|
||||
}
|
||||
|
||||
.product-analysis-quality-rule small {
|
||||
grid-column: 2;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.product-analysis-signal-settings__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.product-analysis-detail-panel > .sycm-business-panel__body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.product-analysis-detail-panel > .sycm-business-panel__header {
|
||||
min-height: 64px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-analysis-detail-panel .sycm-business-panel__heading,
|
||||
.product-analysis-detail-panel .sycm-business-panel__copy,
|
||||
.product-analysis-detail-panel .sycm-business-panel__title {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-analysis-selected-product {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-analysis-selected-product .ant-image,
|
||||
.product-analysis-selected-product img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
background: var(--sycm-bg-subtle);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-analysis-selected-product__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.product-analysis-selected-product__copy strong,
|
||||
.product-analysis-selected-product__copy > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-analysis-selected-product__copy strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-body);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.product-analysis-selected-product__copy > span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-regular);
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.product-analysis-detail-panel .sycm-module-tabs {
|
||||
border-top: 1px solid var(--sycm-border-light);
|
||||
border-bottom: 1px solid var(--sycm-border-light);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.product-analysis-tab-content {
|
||||
min-height: 280px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.product-analysis-tab-metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.product-analysis-tab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-analysis-tab-grid.is-table-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-analysis-trend-block,
|
||||
.product-analysis-history-block {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sycm-border-base);
|
||||
border-radius: var(--sycm-radius-lg);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.product-analysis-trend-block {
|
||||
padding: 14px 16px 8px;
|
||||
}
|
||||
|
||||
.product-analysis-history-block h3 {
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.product-analysis-trend-block h3,
|
||||
.product-analysis-history-block h3 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.product-analysis-history-block .ant-table-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-analysis-history-block .product-analysis-issue-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-analysis-history-block .product-analysis-issue-row:focus-visible > td {
|
||||
background: var(--sycm-color-primary-soft);
|
||||
outline: 1px solid var(--sycm-color-primary-border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.product-analysis-view-orders {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--sycm-color-primary);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
}
|
||||
|
||||
.product-analysis-order-detail {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@container product-analysis (max-width: 1180px) {
|
||||
.product-analysis-filters {
|
||||
grid-template-columns: 100px minmax(180px, 1fr) minmax(220px, 1fr);
|
||||
}
|
||||
|
||||
.product-analysis-range-shortcuts,
|
||||
.product-analysis-filters .ant-picker,
|
||||
.product-analysis-filters .ant-checkbox-wrapper {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.product-analysis-range-shortcuts {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.product-analysis-metrics {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .sycm-dashboard-metric:nth-child(3n) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.product-analysis-tab-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@container product-analysis (max-width: 1500px) {
|
||||
.product-analysis-filter-panel .sycm-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.product-analysis-filter-panel .sycm-toolbar > .ant-space {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
@container product-analysis (max-width: 760px) {
|
||||
.product-analysis-filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.product-analysis-range-shortcuts,
|
||||
.product-analysis-filters .ant-picker,
|
||||
.product-analysis-filters .ant-input-affix-wrapper,
|
||||
.product-analysis-filters .ant-checkbox-wrapper {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.product-analysis-metrics,
|
||||
.product-analysis-tab-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .sycm-dashboard-metric:nth-child(3n) {
|
||||
border-right: 1px solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .sycm-dashboard-metric:nth-child(2n) {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container product-analysis (max-width: 480px) {
|
||||
.product-analysis-filters,
|
||||
.product-analysis-metrics,
|
||||
.product-analysis-tab-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-analysis-filters > * {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .sycm-dashboard-metric {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.product-analysis-overview-panel .sycm-dashboard-metric:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
@ -1,340 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Col, Drawer, Dropdown, Image, Input, Row, Select,
|
||||
Segmented, Space, Spin, Statistic, Table, Tabs, Tag, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import {
|
||||
getProductMonitoringList,
|
||||
getProductMonitoringFilters,
|
||||
getProductMonitoringOperators,
|
||||
} from '../../api/productMonitoring';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const number = (value, digits = 2) => value === null || value === undefined || value === ''
|
||||
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
const int = (value) => number(value, 0);
|
||||
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||||
|
||||
const COMPANY_TAG_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' };
|
||||
const DEPARTMENT_TAG_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' };
|
||||
const STATUS_TABS = [
|
||||
{ key: '销售', label: '销售' },
|
||||
{ key: '停产', label: '停产' },
|
||||
{ key: '退市', label: '退市' },
|
||||
{ key: '__all__', label: '全部' },
|
||||
];
|
||||
|
||||
const toOptions = (list = []) => (Array.isArray(list) ? list : [])
|
||||
.filter(Boolean)
|
||||
.map((value) => ({ value, label: value }));
|
||||
|
||||
const ProductMonitoringPanel = () => {
|
||||
const [status, setStatus] = useState('销售');
|
||||
const [filters, setFilters] = useState({
|
||||
category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '',
|
||||
purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '',
|
||||
});
|
||||
const [filterOptions, setFilterOptions] = useState({ categories: [], suppliers: [], warehouses: [], purchasers: [] });
|
||||
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, latestDate: null });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sorter, setSorter] = useState({ field: 'companySales7d', order: 'DESC' });
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [operators, setOperators] = useState([]);
|
||||
const [operatorsLoading, setOperatorsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getProductMonitoringFilters()
|
||||
.then((result) => {
|
||||
const payload = result?.data || {};
|
||||
if (cancelled) return;
|
||||
setFilterOptions({
|
||||
categories: payload.categories || [],
|
||||
suppliers: payload.suppliers || [],
|
||||
warehouses: payload.warehouses || [],
|
||||
purchasers: payload.purchasers || [],
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const query = useMemo(() => {
|
||||
const params = {
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sorter.field,
|
||||
sortOrder: sorter.order,
|
||||
};
|
||||
if (status === '__all__') params.includeAll = '1';
|
||||
else params.saleStatus = status;
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length) params[key] = value.join(',');
|
||||
} else if (value) {
|
||||
params[key] = value;
|
||||
}
|
||||
});
|
||||
return params;
|
||||
}, [page, pageSize, sorter, status, filters]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getProductMonitoringList(query);
|
||||
setData(result?.data || { rows: [], total: 0, summary: {} });
|
||||
} catch (requestError) {
|
||||
setError(requestError?.response?.data?.error || requestError?.message || '加载商品监控数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const changeStatus = (key) => {
|
||||
setStatus(key);
|
||||
setPage(1);
|
||||
};
|
||||
const changeFilter = (key) => (value) => {
|
||||
setFilters((old) => ({ ...old, [key]: value || '' }));
|
||||
setPage(1);
|
||||
};
|
||||
const changeMultiFilter = (key) => (value) => {
|
||||
setFilters((old) => ({ ...old, [key]: value || [] }));
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '',
|
||||
purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '',
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openDetail = async (record) => {
|
||||
setSelected(record);
|
||||
setOperators([]);
|
||||
setOperatorsLoading(true);
|
||||
try {
|
||||
const result = await getProductMonitoringOperators(record.skuCode || record.skuId);
|
||||
setOperators(result?.data?.operators || []);
|
||||
} catch (requestError) {
|
||||
message.error(requestError?.response?.data?.error || requestError?.message || '加载运营明细失败');
|
||||
} finally {
|
||||
setOperatorsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '图片', dataIndex: 'imageUrl', width: 64, fixed: 'left', exportable: false, render: (value) => (value ? <Image src={value} width={36} height={36} style={{ objectFit: 'cover', borderRadius: 4 }} placeholder /> : <span className="pm-muted">--</span>) },
|
||||
{ title: '品类', dataIndex: 'category', width: 150, ellipsis: true, sorter: true },
|
||||
{ title: 'SPU', dataIndex: 'spu', width: 130, sorter: true },
|
||||
{ title: 'SKU', dataIndex: 'skuId', width: 150, fixed: 'left', sorter: true, render: (value, row) => <Button type="link" className="pm-id-link" onClick={() => openDetail(row)}>{value}</Button> },
|
||||
{ title: '条码', dataIndex: 'barcode', width: 150, ellipsis: true },
|
||||
{ title: '产品名称', dataIndex: 'productName', width: 240, ellipsis: true, sorter: true, render: (value) => <Tooltip title={value}>{text(value)}</Tooltip> },
|
||||
{ title: '材质', dataIndex: 'material', width: 100, ellipsis: true, render: text },
|
||||
{ title: '颜色', dataIndex: 'color', width: 100, ellipsis: true, render: text },
|
||||
{ title: '尺寸', dataIndex: 'size', width: 140, ellipsis: true, render: text },
|
||||
{ title: '销售状态', dataIndex: 'saleStatus', width: 90, sorter: true, render: (v) => <Tag color={v === '销售' ? 'green' : v === '停产' ? 'orange' : 'default'}>{text(v)}</Tag> },
|
||||
{ title: '供应商', dataIndex: 'supplier', width: 180, ellipsis: true, sorter: true, render: text },
|
||||
{ title: '发货仓库', dataIndex: 'warehouse', width: 120, ellipsis: true, sorter: true, render: text },
|
||||
{ title: '公司SKU负责人', dataIndex: 'companyOwner', width: 130, sorter: true, render: text },
|
||||
{ title: '采购负责人', dataIndex: 'purchaser', width: 110, sorter: true, render: text },
|
||||
{ title: '公司近7日日销', dataIndex: 'companySales7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||||
{ title: '可使用库存', dataIndex: 'availableStock', width: 110, align: 'right', sorter: true, render: int },
|
||||
{ title: '在途库存', dataIndex: 'inTransitStock', width: 100, align: 'right', render: (v) => <span className="pm-muted">{text(v)}</span> },
|
||||
{ title: '可使用天数', dataIndex: 'stockDays', width: 110, align: 'right', sorter: true, render: (v) => number(v, 1) },
|
||||
{ title: '公司商品标签', dataIndex: 'companyTag', width: 110, sorter: true, render: (v) => (v ? <Tag color={COMPANY_TAG_COLOR[v] || 'default'}>{v}</Tag> : <span className="pm-muted">--</span>) },
|
||||
{ title: '部门', dataIndex: 'department', width: 110, ellipsis: true, sorter: true, render: text },
|
||||
{ title: '部门的标签', dataIndex: 'departmentTag', width: 110, sorter: true, render: (v) => (v ? <Tag color={DEPARTMENT_TAG_COLOR[v] || 'default'}>{v}</Tag> : <span className="pm-muted">--</span>) },
|
||||
{ title: '部门SKU负责人', dataIndex: 'departmentOwner', width: 130, sorter: true, render: text },
|
||||
{ title: '近30天销售额', dataIndex: 'performance30d', width: 130, align: 'right', sorter: true, render: (v) => number(v) },
|
||||
{ title: '近7天销售额', dataIndex: 'performance7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||||
{ title: '部门近7日日销', dataIndex: 'departmentSales7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||||
{ title: '部门未发货数量', dataIndex: 'departmentUnshipped', width: 120, align: 'right', render: (v) => <span className="pm-muted">{text(v)}</span> },
|
||||
{ title: '建议备货数量', dataIndex: 'suggestedReplenish', width: 120, align: 'right', sorter: true, render: int },
|
||||
{ title: '部门主销售渠道', dataIndex: 'mainSalesChannel', width: 180, ellipsis: true, sorter: true, render: text },
|
||||
{ title: '部门主销售运营', dataIndex: 'mainSalesOperator', width: 130, sorter: true, render: text },
|
||||
], []);
|
||||
|
||||
const exportColumns = useMemo(
|
||||
() => columns.filter((column) => column.exportable !== false),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const handleExport = async (format) => {
|
||||
if (!data.rows.length) {
|
||||
message.warning('暂无数据可导出');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await exportTableFile(format, `商品监控_${status === '__all__' ? '全部' : status}_${data.latestDate ? String(data.latestDate).slice(0, 10) : '最新'}`, exportColumns, data.rows, '商品监控');
|
||||
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
|
||||
} catch (exportError) {
|
||||
message.error(exportError?.message || '导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const summary = data.summary || {};
|
||||
const operatorColumns = [
|
||||
{ title: '运营人员', dataIndex: 'operator', width: 140 },
|
||||
{ title: '日销(近7天日均件数)', dataIndex: 'dailySales', align: 'right', render: (v) => number(v) },
|
||||
{ title: '近7天销售件数', dataIndex: 'salesQty7d', align: 'right', render: int },
|
||||
{ title: '近7天销售额', dataIndex: 'salesAmount7d', align: 'right', render: (v) => number(v) },
|
||||
{ title: '主销售渠道', dataIndex: 'mainChannel', render: text },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="pm-filter-card" bordered={false}>
|
||||
<div className="pm-status-bar">
|
||||
<Text type="secondary">销售状态</Text>
|
||||
<Segmented
|
||||
value={status}
|
||||
onChange={changeStatus}
|
||||
options={STATUS_TABS.map((item) => ({ label: item.label, value: item.key }))}
|
||||
/>
|
||||
</div>
|
||||
<Space wrap size={[12, 12]}>
|
||||
<Select allowClear showSearch placeholder="品类" options={toOptions(filterOptions.categories)} value={filters.category || undefined} onChange={changeFilter('category')} style={{ width: 180 }} />
|
||||
<Select allowClear showSearch placeholder="供应商" options={toOptions(filterOptions.suppliers)} value={filters.supplier || undefined} onChange={changeFilter('supplier')} style={{ width: 200 }} />
|
||||
<Select allowClear showSearch placeholder="发货仓库" options={toOptions(filterOptions.warehouses)} value={filters.warehouse || undefined} onChange={changeFilter('warehouse')} style={{ width: 150 }} />
|
||||
<Select allowClear showSearch placeholder="采购负责人" options={toOptions(filterOptions.purchasers)} value={filters.purchaser || undefined} onChange={changeFilter('purchaser')} style={{ width: 150 }} />
|
||||
<Select
|
||||
mode="multiple" allowClear placeholder="公司标签" maxTagCount="responsive"
|
||||
options={['S', 'A', 'B', 'C', 'D'].map((value) => ({ value, label: value }))}
|
||||
value={filters.companyTag} onChange={changeMultiFilter('companyTag')} style={{ minWidth: 160 }}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple" allowClear placeholder="部门标签" maxTagCount="responsive"
|
||||
options={['G-S', 'G-A', 'G-B', 'G-C'].map((value) => ({ value, label: value }))}
|
||||
value={filters.departmentTag} onChange={changeMultiFilter('departmentTag')} style={{ minWidth: 160 }}
|
||||
/>
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="SPU" value={filters.spu} onChange={(e) => changeFilter('spu')(e.target.value)} style={{ width: 150 }} />
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="SKU / 条码" value={filters.sku} onChange={(e) => changeFilter('sku')(e.target.value)} style={{ width: 160 }} />
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="产品名称" value={filters.productName} onChange={(e) => changeFilter('productName')(e.target.value)} style={{ width: 180 }} />
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="部门SKU负责人" value={filters.departmentOwner} onChange={(e) => changeFilter('departmentOwner')(e.target.value)} style={{ width: 160 }} />
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||
|
||||
<Row gutter={[12, 12]} className="pm-summary-row">
|
||||
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-blue"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-red"><Statistic title="零库存 SKU" value={summary.outOfStockCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-orange"><Statistic title="可用天数 < 7 天" value={summary.lowStockDaysCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-green"><Statistic title="建议备货合计" value={summary.totalSuggestedReplenish || 0} suffix={`(${summary.needReplenishCount || 0} 个 SKU)`} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
className="pm-panel"
|
||||
title={(
|
||||
<Space size={12} wrap>
|
||||
<span>商品明细({data.total || 0} 条)</span>
|
||||
{data.latestDate ? <Text type="secondary" className="pm-panel__date">统计日 {String(data.latestDate).slice(0, 10)}</Text> : null}
|
||||
</Space>
|
||||
)}
|
||||
extra={(
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'xlsx', label: '导出 XLSX' },
|
||||
{ key: 'csv', label: '导出 CSV' },
|
||||
],
|
||||
onClick: ({ key }) => handleExport(key),
|
||||
}}>
|
||||
<Button icon={<DownloadOutlined />}>导出</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
>
|
||||
<Spin spinning={loading} tip="正在加载商品监控数据…">
|
||||
<Table
|
||||
rowKey="skuId"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={data.rows || []}
|
||||
onChange={(pagination, _filter, nextSorter) => {
|
||||
setPage(pagination.current || 1);
|
||||
setPageSize(pagination.pageSize || 50);
|
||||
if (nextSorter?.field) {
|
||||
setSorter({
|
||||
field: nextSorter.field,
|
||||
order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||
});
|
||||
}
|
||||
}}
|
||||
pagination={{
|
||||
current: data.page || page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total: data.total || 0,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [50, 100, 200],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 3400, y: 'min(520px, calc(100vh - 520px))' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
className="pm-detail-drawer"
|
||||
title={selected ? `${selected.skuId} · 部门运营明细` : '部门运营明细'}
|
||||
width={820}
|
||||
open={Boolean(selected)}
|
||||
onClose={() => setSelected(null)}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
<Space size={[16, 8]} wrap className="pm-detail-meta">
|
||||
<Text type="secondary">产品名称:{text(selected.productName)}</Text>
|
||||
<Text type="secondary">部门:{text(selected.department)}</Text>
|
||||
<Text type="secondary">部门标签:{text(selected.departmentTag)}</Text>
|
||||
<Text type="secondary">部门SKU负责人:{text(selected.departmentOwner)}</Text>
|
||||
</Space>
|
||||
<Table
|
||||
className="pm-daily-table"
|
||||
loading={operatorsLoading}
|
||||
rowKey="operator"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={operators}
|
||||
columns={operatorColumns}
|
||||
locale={{ emptyText: '该 SKU 近 7 天暂无销售明细' }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ProductMonitoring = () => (
|
||||
<SycmPage className="pm-page">
|
||||
<header className="pm-header">
|
||||
<div className="pm-header__heading">
|
||||
<Title level={3}>商品监控</Title>
|
||||
<Text type="secondary">全公司商品档案、库存、标签分级与部门运营指标;点击 SKU 可下钻查看部门运营明细</Text>
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
className="pm-tabs"
|
||||
activeKey="all"
|
||||
items={[{ key: 'all', label: '全公司', children: <ProductMonitoringPanel /> }]}
|
||||
/>
|
||||
</SycmPage>
|
||||
);
|
||||
|
||||
export default ProductMonitoring;
|
||||
@ -1,167 +0,0 @@
|
||||
.pm-page {
|
||||
container-name: product-monitoring;
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
min-height: calc(100% - 46px);
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
background: var(--sycm-bg-page-soft);
|
||||
}
|
||||
|
||||
.pm-header {
|
||||
display: flex;
|
||||
min-height: 84px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-lg);
|
||||
margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0;
|
||||
padding: 14px calc(var(--sycm-page-padding-x) + 2px);
|
||||
border-bottom: 1px solid var(--sycm-border-base);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
.pm-header__heading { min-width: 0; }
|
||||
.pm-header h3 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-page);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: var(--sycm-line-height-tight);
|
||||
}
|
||||
.pm-header__heading .ant-typography {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.pm-tabs .ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding: 0 calc(var(--sycm-page-padding-x) + 2px);
|
||||
border-bottom: 1px solid var(--sycm-border-base);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
.pm-tabs .ant-tabs-tab {
|
||||
margin: 0;
|
||||
padding: 12px 4px;
|
||||
font-size: var(--sycm-font-size-title);
|
||||
}
|
||||
.pm-tabs .ant-tabs-content-holder,
|
||||
.pm-tabs .ant-tabs-content,
|
||||
.pm-tabs .ant-tabs-tabpane { min-height: 100%; }
|
||||
.pm-tabs .ant-tabs-tabpane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
padding-top: var(--sycm-page-section-gap);
|
||||
}
|
||||
|
||||
.pm-filter-card,
|
||||
.pm-panel,
|
||||
.pm-summary-card {
|
||||
border: 1px solid var(--sycm-border-card) !important;
|
||||
border-radius: var(--sycm-radius-lg) !important;
|
||||
box-shadow: var(--sycm-shadow-card) !important;
|
||||
}
|
||||
.pm-filter-card { margin-bottom: 0; }
|
||||
.pm-filter-card .ant-card-body { padding: 14px 16px; }
|
||||
.pm-filter-card .ant-space { width: 100%; }
|
||||
.pm-filter-card .ant-space-item { max-width: 100%; }
|
||||
.pm-filter-card .ant-select,
|
||||
.pm-filter-card .ant-input-affix-wrapper { max-width: 100%; }
|
||||
|
||||
.pm-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pm-summary-row { margin-top: -4px; }
|
||||
.pm-summary-row .ant-col { display: flex; }
|
||||
.pm-summary-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 98px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pm-summary-card::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: var(--sycm-color-primary);
|
||||
content: '';
|
||||
}
|
||||
.pm-summary-card.is-red::before { background: var(--sycm-color-danger); }
|
||||
.pm-summary-card.is-orange::before { background: var(--sycm-color-orange); }
|
||||
.pm-summary-card.is-green::before { background: var(--sycm-color-success); }
|
||||
.pm-summary-card .ant-card-body {
|
||||
display: flex;
|
||||
min-height: 98px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.pm-summary-card .ant-statistic { width: 100%; }
|
||||
.pm-summary-card .ant-statistic-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
.pm-summary-card .ant-statistic-content {
|
||||
color: var(--sycm-text-strong);
|
||||
font-family: var(--sycm-font-family-number);
|
||||
font-size: var(--sycm-font-size-metric);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.pm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); }
|
||||
|
||||
.pm-panel { margin-bottom: 0; overflow: hidden; }
|
||||
.pm-panel .ant-card-head {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom-color: var(--sycm-border-light);
|
||||
}
|
||||
.pm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); }
|
||||
.pm-panel .ant-card-body { padding: 0 16px 16px; }
|
||||
.pm-panel .ant-table { font-size: var(--sycm-font-size-caption); }
|
||||
.pm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); }
|
||||
.pm-panel .ant-table-placeholder .ant-table-cell { height: 180px; }
|
||||
.pm-panel__date { font-size: var(--sycm-font-size-caption); }
|
||||
.pm-id-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-family: var(--sycm-font-family-number);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
.pm-muted { color: var(--sycm-text-tertiary); }
|
||||
.pm-daily-table { margin-top: var(--sycm-space-md); }
|
||||
.pm-detail-meta { margin-bottom: var(--sycm-space-sm); }
|
||||
|
||||
.pm-detail-drawer .ant-drawer-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom-color: var(--sycm-border-light);
|
||||
}
|
||||
.pm-detail-drawer .ant-drawer-title {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
.pm-detail-drawer .ant-drawer-body { padding: 20px; }
|
||||
|
||||
@container product-monitoring (max-width: 900px) {
|
||||
.pm-filter-card .ant-space-item { flex: 1 1 180px; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.pm-header {
|
||||
min-height: 72px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding-block: 12px;
|
||||
}
|
||||
.pm-filter-card .ant-select,
|
||||
.pm-filter-card .ant-input-affix-wrapper { width: 100%; }
|
||||
.pm-panel .ant-card-body { padding-inline: 10px; }
|
||||
.pm-detail-drawer .ant-drawer-body { padding: 14px; }
|
||||
}
|
||||
@ -1,610 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
SycmBusinessPanel,
|
||||
SycmDashboardMetric,
|
||||
SycmPage,
|
||||
} from '../../components/Common';
|
||||
import {
|
||||
getPromotionModuleList,
|
||||
getPromotionModuleSummary,
|
||||
getPromotionModuleShops,
|
||||
} from '../../api/promotionModule';
|
||||
import './style.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const PROMOTION_TYPES = [
|
||||
{ key: 'stable-cost', label: '稳定成本推广' },
|
||||
{ key: 'full-store', label: '全店托管' },
|
||||
];
|
||||
|
||||
const DASHBOARD_METRICS = [
|
||||
{ key: 'orderSpend', label: '成交花费(元)' },
|
||||
{ key: 'gmv', label: '交易额(元)' },
|
||||
{ key: 'roi', label: '实际投产比' },
|
||||
{ key: 'spend', label: '总花费(元)' },
|
||||
{ key: 'netGmv', label: '净交易额(元)' },
|
||||
{ key: 'netRoi', label: '净实际投产比' },
|
||||
{ key: 'netOrderNum', label: '净成交笔数' },
|
||||
{ key: 'costPerNetOrder', label: '每笔净成交花费(元)' },
|
||||
];
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ label: '实时', value: 'realtime' },
|
||||
{ label: '昨日', value: 'yesterday' },
|
||||
{ label: '近7天', value: 'last7d' },
|
||||
{ label: '近30天', value: 'last30d' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' },
|
||||
];
|
||||
|
||||
function fmtMoney(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function fmtRate(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return `${(n * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function fmtNum(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function fmtPlain(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
const PromotionModule = () => {
|
||||
const [promotionType, setPromotionType] = useState('stable-cost');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [addProductOpen, setAddProductOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
|
||||
const [sorter, setSorter] = useState({ field: 'orderSpend', order: 'descend' });
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
const [shops, setShops] = useState([]);
|
||||
const [selectedShop, setSelectedShop] = useState('');
|
||||
const [period, setPeriod] = useState('last7d');
|
||||
const [customDateRange, setCustomDateRange] = useState(null);
|
||||
const [filterDateLabel, setFilterDateLabel] = useState('');
|
||||
|
||||
const fetchShops = useCallback(async () => {
|
||||
try {
|
||||
const res = await getPromotionModuleShops();
|
||||
if (res?.data) setShops(res.data);
|
||||
} catch { /* 忽略 */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShops();
|
||||
}, [fetchShops]);
|
||||
|
||||
const buildParams = useCallback(() => {
|
||||
const p = {};
|
||||
if (period === 'custom' && customDateRange?.length === 2) {
|
||||
p.period = 'custom';
|
||||
p.start = customDateRange[0].format('YYYY-MM-DD');
|
||||
p.end = customDateRange[1].format('YYYY-MM-DD');
|
||||
} else {
|
||||
p.period = period;
|
||||
}
|
||||
if (selectedShop) p.shopName = selectedShop;
|
||||
if (keyword.trim()) p.keyword = keyword.trim();
|
||||
return p;
|
||||
}, [period, customDateRange, selectedShop, keyword]);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const params = buildParams();
|
||||
const res = await getPromotionModuleSummary(params);
|
||||
if (res?.data) {
|
||||
setSummary(res.data);
|
||||
setFilterDateLabel(res.data.filterDate || '');
|
||||
}
|
||||
} catch {
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, [buildParams]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
...buildParams(),
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: sorter.field,
|
||||
sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||
};
|
||||
const result = await getPromotionModuleList(params);
|
||||
const data = result?.data;
|
||||
if (data) {
|
||||
setDataSource(data.list || []);
|
||||
setTotal(data.total || 0);
|
||||
}
|
||||
} catch {
|
||||
setDataSource([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [buildParams, pagination, sorter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
fetchData();
|
||||
}, [fetchSummary, fetchData]);
|
||||
|
||||
const handleTableChange = (pag, _filters, sort) => {
|
||||
setPagination({ current: pag.current, pageSize: pag.pageSize });
|
||||
if (sort.field) {
|
||||
setSorter({ field: sort.field, order: sort.order || 'descend' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
};
|
||||
|
||||
const handlePeriodChange = (value) => {
|
||||
setPeriod(value);
|
||||
setCustomDateRange(null);
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
};
|
||||
|
||||
const handleShopChange = (value) => {
|
||||
setSelectedShop(value);
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
};
|
||||
|
||||
const summaryMetrics = useMemo(() => {
|
||||
if (!summary) return DASHBOARD_METRICS.map((m) => ({ ...m, value: '--' }));
|
||||
const changes = summary.changes || {};
|
||||
return DASHBOARD_METRICS.map((m) => {
|
||||
const raw = summary[m.key];
|
||||
let formatted = '--';
|
||||
if (raw != null && raw !== '-') {
|
||||
if (['orderSpend', 'gmv', 'spend', 'netGmv', 'costPerNetOrder'].includes(m.key)) {
|
||||
formatted = fmtMoney(raw);
|
||||
} else if (m.key === 'netOrderNum') {
|
||||
formatted = fmtNum(raw);
|
||||
} else {
|
||||
formatted = fmtPlain(raw);
|
||||
}
|
||||
}
|
||||
// 环比:与上一等长周期对比
|
||||
const change = changes[m.key];
|
||||
let trend;
|
||||
let trendTone;
|
||||
if (Number.isFinite(change)) {
|
||||
const percent = Math.abs(change * 100).toFixed(1);
|
||||
if (change > 0) {
|
||||
trend = `↑ ${percent}%`;
|
||||
trendTone = 'up';
|
||||
} else if (change < 0) {
|
||||
trend = `↓ ${percent}%`;
|
||||
trendTone = 'down';
|
||||
} else {
|
||||
trend = '持平';
|
||||
}
|
||||
}
|
||||
return { ...m, value: formatted, trend, trendTone };
|
||||
});
|
||||
}, [summary]);
|
||||
|
||||
const baseColumns = [
|
||||
{
|
||||
title: '推广商品',
|
||||
key: 'product',
|
||||
width: 280,
|
||||
fixed: 'left',
|
||||
render: (_, record) => (
|
||||
<div className="promotion-product-cell">
|
||||
{record.thumbUrl ? (
|
||||
<img src={record.thumbUrl} alt={record.goodsName || '商品'} />
|
||||
) : (
|
||||
<span className="promotion-product-cell__image">暂无</span>
|
||||
)}
|
||||
<div>
|
||||
<strong>{record.goodsName || '未命名商品'}</strong>
|
||||
<span>商品 ID:{record.goodsId || '-'}</span>
|
||||
<span>店铺:{record.shopName || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成交花费(元)',
|
||||
dataIndex: 'orderSpend',
|
||||
key: 'orderSpend',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
{
|
||||
title: '交易额(元)',
|
||||
dataIndex: 'gmv',
|
||||
key: 'gmv',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
{
|
||||
title: '实际投产比',
|
||||
dataIndex: 'roi',
|
||||
key: 'roi',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtPlain(v),
|
||||
},
|
||||
{
|
||||
title: '目标费比ROI',
|
||||
dataIndex: 'targetRoi',
|
||||
key: 'targetRoi',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => {
|
||||
const n = Number(String(v ?? '').trim());
|
||||
if (!v || !Number.isFinite(n) || n <= 0) return '-';
|
||||
return fmtPlain(n / 10000);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '总花费(元)',
|
||||
dataIndex: 'spend',
|
||||
key: 'spend',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
{
|
||||
title: '成交笔数',
|
||||
dataIndex: 'orderNum',
|
||||
key: 'orderNum',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtNum(v),
|
||||
},
|
||||
{
|
||||
title: '净交易额(元)',
|
||||
dataIndex: 'netGmv',
|
||||
key: 'netGmv',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
{
|
||||
title: '净实际投产比',
|
||||
dataIndex: 'netRoi',
|
||||
key: 'netRoi',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtPlain(v),
|
||||
},
|
||||
{
|
||||
title: '净成交笔数',
|
||||
dataIndex: 'netOrderNum',
|
||||
key: 'netOrderNum',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtNum(v),
|
||||
},
|
||||
{
|
||||
title: '净交易额占比',
|
||||
dataIndex: 'netRate',
|
||||
key: 'netRate',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtRate(v),
|
||||
},
|
||||
{
|
||||
title: '结算交易额(元)',
|
||||
dataIndex: 'settlementGmv',
|
||||
key: 'settlementGmv',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
{
|
||||
title: '结算投产比',
|
||||
dataIndex: 'settlementRoi',
|
||||
key: 'settlementRoi',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtPlain(v),
|
||||
},
|
||||
{
|
||||
title: '结算笔数',
|
||||
dataIndex: 'settlementOrder',
|
||||
key: 'settlementOrder',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtNum(v),
|
||||
},
|
||||
{
|
||||
title: '退款率',
|
||||
dataIndex: 'refundGmvRate30d',
|
||||
key: 'refundGmvRate30d',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtRate(v),
|
||||
},
|
||||
{
|
||||
title: '退款豁免率',
|
||||
dataIndex: 'exemptRefundGmvRate30d',
|
||||
key: 'exemptRefundGmvRate30d',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtRate(v),
|
||||
},
|
||||
{
|
||||
title: '曝光量',
|
||||
dataIndex: 'impression',
|
||||
key: 'impression',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtNum(v),
|
||||
},
|
||||
{
|
||||
title: '点击量',
|
||||
dataIndex: 'click',
|
||||
key: 'click',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtNum(v),
|
||||
},
|
||||
{
|
||||
title: '点击率',
|
||||
dataIndex: 'ctr',
|
||||
key: 'ctr',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtRate(v),
|
||||
},
|
||||
{
|
||||
title: '点击转化率',
|
||||
dataIndex: 'cvr',
|
||||
key: 'cvr',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtRate(v),
|
||||
},
|
||||
{
|
||||
title: '每笔成交花费(元)',
|
||||
dataIndex: 'transactionCost',
|
||||
key: 'transactionCost',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
{
|
||||
title: '每笔成交金额(元)',
|
||||
dataIndex: 'avgPayAmount',
|
||||
key: 'avgPayAmount',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
{
|
||||
title: '直接交易额(元)',
|
||||
dataIndex: 'directGmv',
|
||||
key: 'directGmv',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
{
|
||||
title: '间接交易额(元)',
|
||||
dataIndex: 'indirectGmv',
|
||||
key: 'indirectGmv',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
sorter: true,
|
||||
render: (v) => fmtMoney(v),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SycmPage className="promotion-module-page">
|
||||
<SycmBusinessPanel className="promotion-dashboard" aria-label="推广数据仪表盘">
|
||||
<div className="promotion-dashboard__header">
|
||||
<div>
|
||||
<h2>推广数据</h2>
|
||||
<span>包含稳定成本推广和全店托管数据</span>
|
||||
</div>
|
||||
<div className="promotion-dashboard__tools">
|
||||
<span>统计时间:{filterDateLabel || '--'}</span>
|
||||
{summary?.previousRange ? (
|
||||
<span>上一周期:{summary.previousRange}</span>
|
||||
) : null}
|
||||
<Tooltip title="指标设置">
|
||||
<Button icon={<SettingOutlined />} aria-label="指标设置" onClick={() => setSettingsOpen(true)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Spin spinning={summaryLoading}>
|
||||
<div className="promotion-dashboard__metrics">
|
||||
{summaryMetrics.map((item) => (
|
||||
<SycmDashboardMetric
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
help={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
</SycmBusinessPanel>
|
||||
|
||||
<SycmBusinessPanel className="promotion-workspace" aria-label="推广明细工作台">
|
||||
<Tabs
|
||||
className="promotion-type-tabs"
|
||||
activeKey={promotionType}
|
||||
items={PROMOTION_TYPES}
|
||||
onChange={setPromotionType}
|
||||
/>
|
||||
|
||||
<div className="promotion-filterbar">
|
||||
<Space size={12} wrap>
|
||||
<Select
|
||||
className="promotion-filterbar__shop"
|
||||
value={selectedShop || undefined}
|
||||
placeholder="全部店铺"
|
||||
allowClear
|
||||
onChange={handleShopChange}
|
||||
options={shops.map((s) => ({ label: s, value: s }))}
|
||||
/>
|
||||
<Space.Compact>
|
||||
{PERIOD_OPTIONS.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type={period === opt.value ? 'primary' : 'default'}
|
||||
onClick={() => handlePeriodChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type={period === 'custom' ? 'primary' : 'default'}
|
||||
onClick={() => setPeriod('custom')}
|
||||
>
|
||||
自定义
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{period === 'custom' && (
|
||||
<RangePicker
|
||||
value={customDateRange}
|
||||
onChange={setCustomDateRange}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
className="promotion-filterbar__search"
|
||||
allowClear
|
||||
value={keyword}
|
||||
placeholder="搜索商品ID / 商品名"
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
suffix={<SearchOutlined style={{ color: 'var(--sycm-text-tertiary)' }} />}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => {
|
||||
setKeyword('');
|
||||
setSelectedShop('');
|
||||
setPeriod('last7d');
|
||||
setCustomDateRange(null);
|
||||
setPagination({ current: 1, pageSize: 20 });
|
||||
}}>刷新</Button>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>查询</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="promotion-selectionbar">
|
||||
<span>已选 {selectedRowKeys.length} 项</span>
|
||||
<span>共 {total} 条推广记录</span>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
className="promotion-table"
|
||||
rowKey="id"
|
||||
columns={baseColumns}
|
||||
dataSource={dataSource}
|
||||
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 3000 }}
|
||||
locale={{ emptyText: loading ? '加载中...' : '暂无推广数据' }}
|
||||
size="small"
|
||||
/>
|
||||
</Spin>
|
||||
</SycmBusinessPanel>
|
||||
|
||||
<Drawer
|
||||
title="推广指标设置"
|
||||
width={520}
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
>
|
||||
<div style={{ color: 'var(--sycm-text-tertiary)', fontSize: 12 }}>指标设置功能开发中</div>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="添加推广商品"
|
||||
width={520}
|
||||
open={addProductOpen}
|
||||
onClose={() => setAddProductOpen(false)}
|
||||
>
|
||||
<Input.Search allowClear placeholder="输入商品ID或商品名称" enterButton="查询" />
|
||||
<div style={{ marginTop: 96, textAlign: 'center', color: 'var(--sycm-text-tertiary)' }}>
|
||||
暂无可添加商品
|
||||
</div>
|
||||
</Drawer>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromotionModule;
|
||||
@ -1,214 +0,0 @@
|
||||
.promotion-module-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
}
|
||||
|
||||
.promotion-dashboard,
|
||||
.promotion-workspace {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.promotion-dashboard {
|
||||
padding: var(--sycm-module-padding);
|
||||
}
|
||||
|
||||
.promotion-dashboard__header,
|
||||
.promotion-actionbar,
|
||||
.promotion-filterbar,
|
||||
.promotion-selectionbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
.promotion-dashboard__header > div:first-child {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.promotion-dashboard__header h2 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.promotion-dashboard__header span,
|
||||
.promotion-dashboard__tools,
|
||||
.promotion-filterbar > span,
|
||||
.promotion-selectionbar {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.promotion-dashboard__tools {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.promotion-dashboard__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(110px, 1fr));
|
||||
gap: var(--sycm-space-sm);
|
||||
margin-top: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.promotion-dashboard__metrics .sycm-dashboard-metric {
|
||||
min-height: 96px;
|
||||
padding: var(--sycm-space-sm);
|
||||
border-radius: var(--sycm-radius-lg);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.promotion-dashboard__metrics .sycm-dashboard-metric__label {
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.promotion-dashboard__metrics .sycm-dashboard-metric__value {
|
||||
font-size: var(--sycm-font-size-metric);
|
||||
}
|
||||
|
||||
.promotion-type-tabs .ant-tabs-nav {
|
||||
min-height: var(--sycm-module-header-min-height);
|
||||
margin: 0 !important;
|
||||
padding: 0 var(--sycm-module-padding);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.promotion-type-tabs .ant-tabs-content-holder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.promotion-actionbar {
|
||||
min-height: calc(var(--sycm-control-height) + var(--sycm-space-lg));
|
||||
padding: var(--sycm-space-sm) var(--sycm-module-padding);
|
||||
}
|
||||
|
||||
.promotion-actionbar > div {
|
||||
display: flex;
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.promotion-filterbar {
|
||||
justify-content: flex-start;
|
||||
padding: var(--sycm-space-sm) var(--sycm-module-padding);
|
||||
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.promotion-filterbar .ant-space {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.promotion-filterbar__shop {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.promotion-filterbar__search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.promotion-selectionbar {
|
||||
min-height: var(--sycm-module-header-min-height);
|
||||
justify-content: flex-start;
|
||||
padding: 0 var(--sycm-module-padding);
|
||||
}
|
||||
|
||||
.promotion-table .ant-table-placeholder .ant-table-cell {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.promotion-product-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.promotion-product-cell > img,
|
||||
.promotion-product-cell__image {
|
||||
display: inline-flex;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
background: var(--sycm-bg-subtle);
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.promotion-product-cell > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.promotion-product-cell strong,
|
||||
.promotion-product-cell span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promotion-product-cell strong {
|
||||
max-width: 180px;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
}
|
||||
|
||||
.promotion-product-cell span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.promotion-budget-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-xxs);
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.promotion-metric-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.promotion-metric-settings .ant-checkbox-wrapper {
|
||||
margin-inline-start: 0;
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.promotion-add-product-empty {
|
||||
margin-top: 96px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.promotion-dashboard__metrics {
|
||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.promotion-filterbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.promotion-filterbar .ant-input-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.promotion-filterbar > span {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@ -1,340 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Input, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, TagsOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getDepartmentTagConfig,
|
||||
saveDepartmentTagConfig,
|
||||
} from '../../api/productMonitoring';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const METRIC_OPTIONS = [
|
||||
{ value: 'sales_qty_7d', label: '近7天销售件数' },
|
||||
{ value: 'sales_amount_7d', label: '近7天销售额' },
|
||||
];
|
||||
const OWNER_TYPE_OPTIONS = [
|
||||
{ value: 'sku_owner', label: 'SKU负责人' },
|
||||
{ value: 'purchaser', label: '采购负责人' },
|
||||
];
|
||||
const LEVEL_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' };
|
||||
|
||||
const readError = (error) => error?.response?.data?.error || error?.message || '操作失败';
|
||||
|
||||
const useDepartmentConfig = () => {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getDepartmentTagConfig();
|
||||
setConfig(result?.data || null);
|
||||
} catch (requestError) {
|
||||
setError(readError(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return { config, setConfig, loading, setLoading, error, setError, load };
|
||||
};
|
||||
|
||||
// ==================== 部门商品标签配置(G-S / G-A / G-B / G-C) ====================
|
||||
|
||||
export const DepartmentTagSettings = () => {
|
||||
const { config, setConfig, loading, error, load } = useDepartmentConfig();
|
||||
const [rules, setRules] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.rules) setRules(config.rules.map((item) => ({ ...item })));
|
||||
}, [config]);
|
||||
|
||||
const patch = (tagLevel, key, value) => {
|
||||
setRules((old) => old.map((item) => (item.tagLevel === tagLevel ? { ...item, [key]: value } : item)));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await saveDepartmentTagConfig({ rules });
|
||||
setConfig(result?.data || null);
|
||||
message.success('部门商品标签已保存,商品监控列表下次加载即生效');
|
||||
} catch (requestError) {
|
||||
message.error(readError(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '部门标签',
|
||||
dataIndex: 'tagLevel',
|
||||
width: 120,
|
||||
render: (value) => <Tag color={LEVEL_COLOR[value] || 'default'}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '判定指标',
|
||||
dataIndex: 'metricKey',
|
||||
width: 180,
|
||||
render: (value, row) => (
|
||||
<Select
|
||||
value={value}
|
||||
options={METRIC_OPTIONS}
|
||||
onChange={(next) => patch(row.tagLevel, 'metricKey', next)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '区间起点(含)',
|
||||
dataIndex: 'thresholdMin',
|
||||
width: 160,
|
||||
render: (value, row) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
placeholder="不限"
|
||||
onChange={(e) => patch(row.tagLevel, 'thresholdMin', e.target.value === '' ? null : Number(e.target.value))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '区间终点(不含)',
|
||||
dataIndex: 'thresholdMax',
|
||||
width: 160,
|
||||
render: (value, row) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
placeholder="不限"
|
||||
onChange={(e) => patch(row.tagLevel, 'thresholdMax', e.target.value === '' ? null : Number(e.target.value))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
width: 110,
|
||||
render: (value, row) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? 0}
|
||||
onChange={(e) => patch(row.tagLevel, 'priority', Number(e.target.value) || 0)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (value, row) => (
|
||||
<Switch checked={!!value} onChange={(next) => patch(row.tagLevel, 'enabled', next)} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-tab-page">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><TagsOutlined /> 部门商品标签配置</Space>}
|
||||
extra={(
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>保存</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||||
<Text type="secondary">
|
||||
商品监控页「部门的标签」按此规则判定:同一 SKU 的<b>近 7 天指标</b>落在 <code>[起点, 终点)</code> 区间即命中,
|
||||
按优先级升序命中即停,未命中留空。留空表示不限(如 G-S 只填起点 500)。
|
||||
</Text>
|
||||
<Table
|
||||
style={{ marginTop: 12 }}
|
||||
rowKey="tagLevel"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
dataSource={rules}
|
||||
columns={columns}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 部门 SKU 负责人配置 ====================
|
||||
|
||||
export const DepartmentOwnerSettings = () => {
|
||||
const { config, setConfig, loading, error, load } = useDepartmentConfig();
|
||||
const [owners, setOwners] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.owners) {
|
||||
setOwners(config.owners.map((item, index) => ({
|
||||
key: `${item.departmentId}-${item.ownerType}-${item.spu || ''}-${item.userId}-${index}`,
|
||||
departmentId: item.departmentId,
|
||||
spu: item.spu || '',
|
||||
ownerType: item.ownerType,
|
||||
userId: item.userId,
|
||||
})));
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const departmentOptions = useMemo(
|
||||
() => (config?.departments || []).map((item) => ({ value: item.id, label: item.name })),
|
||||
[config]
|
||||
);
|
||||
const userOptions = useMemo(
|
||||
() => (config?.users || []).map((item) => ({ value: item.id, label: item.name || item.username })),
|
||||
[config]
|
||||
);
|
||||
|
||||
const patch = (key, field, value) => {
|
||||
setOwners((old) => old.map((item) => (item.key === key ? { ...item, [field]: value } : item)));
|
||||
};
|
||||
const addRow = () => {
|
||||
setOwners((old) => [...old, {
|
||||
key: `new-${Date.now()}`,
|
||||
departmentId: undefined,
|
||||
spu: '',
|
||||
ownerType: 'sku_owner',
|
||||
userId: undefined,
|
||||
}]);
|
||||
};
|
||||
const removeRow = (key) => setOwners((old) => old.filter((item) => item.key !== key));
|
||||
|
||||
const handleSave = async () => {
|
||||
const invalid = owners.find((item) => !item.departmentId || !item.userId);
|
||||
if (invalid) {
|
||||
message.warning('每一行都必须选择部门与负责人');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await saveDepartmentTagConfig({ owners });
|
||||
setConfig(result?.data || null);
|
||||
message.success('部门 SKU 负责人已保存');
|
||||
} catch (requestError) {
|
||||
message.error(readError(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '部门',
|
||||
dataIndex: 'departmentId',
|
||||
width: 200,
|
||||
render: (value, row) => (
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="请选择部门"
|
||||
value={value}
|
||||
options={departmentOptions}
|
||||
onChange={(next) => patch(row.key, 'departmentId', next)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'SPU(留空=部门默认)',
|
||||
dataIndex: 'spu',
|
||||
width: 200,
|
||||
render: (value, row) => (
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="留空表示该部门的默认负责人"
|
||||
onChange={(e) => patch(row.key, 'spu', e.target.value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '负责人类型',
|
||||
dataIndex: 'ownerType',
|
||||
width: 150,
|
||||
render: (value, row) => (
|
||||
<Select
|
||||
value={value}
|
||||
options={OWNER_TYPE_OPTIONS}
|
||||
onChange={(next) => patch(row.key, 'ownerType', next)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'userId',
|
||||
width: 200,
|
||||
render: (value, row) => (
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="请选择负责人"
|
||||
value={value}
|
||||
options={userOptions}
|
||||
onChange={(next) => patch(row.key, 'userId', next)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_, row) => (
|
||||
<Popconfirm title="删除该行?" onConfirm={() => removeRow(row.key)}>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-tab-page">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><TeamOutlined /> 部门 SKU 负责人配置</Space>}
|
||||
extra={(
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={addRow}>新增一行</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>保存</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||||
<Text type="secondary">
|
||||
商品监控页「部门SKU负责人」按此表匹配:先找<b>部门 + SPU</b> 精确配置,找不到再用<b>部门 + SPU 留空</b>的默认负责人。
|
||||
保存会整体覆盖当前配置。
|
||||
</Text>
|
||||
<Table
|
||||
style={{ marginTop: 12 }}
|
||||
rowKey="key"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
dataSource={owners}
|
||||
columns={columns}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DepartmentTagSettings;
|
||||
@ -1,372 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Input, InputNumber, Select, Space, Spin, Table, Tag, Typography, message } from 'antd';
|
||||
import { ReloadOutlined, SaveOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { getProductTagConfig, updateProductTagConfig } from '../../api/admin';
|
||||
import { SycmCard } from '../../components/Common';
|
||||
import './productTagSettings.css';
|
||||
|
||||
const SCALE_TAGS = ['爆款', '热卖款', '动销款', '低动销款'];
|
||||
const SCALE_TAG_ORDER = [...SCALE_TAGS, '滞销款'];
|
||||
const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级'];
|
||||
const SABC_TAGS = ['S', 'A', 'B', 'C', 'D'];
|
||||
|
||||
// 规模标签配色:爆款最优 → 滞销款最差
|
||||
const SCALE_TAG_COLOR = {
|
||||
爆款: 'red',
|
||||
热卖款: 'orange',
|
||||
动销款: 'blue',
|
||||
低动销款: 'default',
|
||||
滞销款: 'default',
|
||||
};
|
||||
|
||||
const SABC_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' };
|
||||
|
||||
const numText = (value) => (
|
||||
value === null || value === undefined || value === ''
|
||||
? '--'
|
||||
: Number(value).toLocaleString('zh-CN')
|
||||
);
|
||||
|
||||
const scaleRowKey = (row) => `${row.category}||${row.scaleTag}`;
|
||||
|
||||
const ProductTagSettings = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [scaleThresholds, setScaleThresholds] = useState([]);
|
||||
const [profitLevels, setProfitLevels] = useState([]);
|
||||
const [sabcMappings, setSabcMappings] = useState([]);
|
||||
const [excludedCategories, setExcludedCategories] = useState([]);
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [scaleTagFilter, setScaleTagFilter] = useState();
|
||||
|
||||
const applyConfig = useCallback((config = {}) => {
|
||||
setScaleThresholds(Array.isArray(config.scaleThresholds) ? config.scaleThresholds : []);
|
||||
setProfitLevels(Array.isArray(config.profitLevels) ? config.profitLevels : []);
|
||||
setSabcMappings(Array.isArray(config.sabcMappings) ? config.sabcMappings : []);
|
||||
setExcludedCategories(Array.isArray(config.excludedCategories) ? config.excludedCategories : []);
|
||||
}, []);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getProductTagConfig();
|
||||
applyConfig(response?.data?.data || response?.data || {});
|
||||
} catch (error) {
|
||||
message.error(error?.response?.data?.error || error?.message || '加载商品标签配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await updateProductTagConfig({
|
||||
scaleThresholds,
|
||||
profitLevels,
|
||||
sabcMappings,
|
||||
excludedCategories,
|
||||
});
|
||||
applyConfig(response?.data?.data || response?.data || {});
|
||||
message.success(
|
||||
`已保存:规模阈值 ${scaleThresholds.length} 行 / 利润标签 ${profitLevels.length} 行 / `
|
||||
+ `SABC 映射 ${sabcMappings.length} 条 / 排除品类 ${excludedCategories.length} 个`
|
||||
);
|
||||
} catch (error) {
|
||||
message.error(error?.response?.data?.error || error?.message || '保存商品标签配置失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 规模阈值:原地更新某一行
|
||||
const patchScaleRow = useCallback((rowKey, field, value) => {
|
||||
setScaleThresholds((rows) => rows.map((row) => (
|
||||
scaleRowKey(row) === rowKey ? { ...row, [field]: value } : row
|
||||
)));
|
||||
}, []);
|
||||
|
||||
// SABC 映射:矩阵单元格更新(不存在则新增)
|
||||
const patchSabcCell = useCallback((scaleTag, profitLevel, sabcTag) => {
|
||||
setSabcMappings((rows) => {
|
||||
const index = rows.findIndex((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel);
|
||||
if (index >= 0) {
|
||||
const next = [...rows];
|
||||
next[index] = { ...next[index], sabcTag };
|
||||
return next;
|
||||
}
|
||||
return [...rows, { scaleTag, profitLevel, sabcTag, enabled: true }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sabcValueOf = useCallback((scaleTag, profitLevel) => {
|
||||
const hit = sabcMappings.find((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel);
|
||||
return hit?.sabcTag;
|
||||
}, [sabcMappings]);
|
||||
|
||||
// 候选品类:已配置的品类(含 jst_sku.category 全名)作为联想项,
|
||||
// 未配置的(如「其他 - 配件」)直接手动输入完整值。
|
||||
const dbCategoryOptions = useMemo(() => {
|
||||
const set = new Set();
|
||||
scaleThresholds.forEach((row) => {
|
||||
if (row.category) set.add(row.category);
|
||||
});
|
||||
excludedCategories.forEach((item) => set.add(item));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}, [scaleThresholds, excludedCategories]);
|
||||
|
||||
const filteredScaleRows = useMemo(() => {
|
||||
const keyword = categoryFilter.trim();
|
||||
return scaleThresholds.filter((row) => (
|
||||
(!keyword || String(row.displayName || '').includes(keyword) || String(row.category || '').includes(keyword))
|
||||
&& (!scaleTagFilter || row.scaleTag === scaleTagFilter)
|
||||
));
|
||||
}, [scaleThresholds, categoryFilter, scaleTagFilter]);
|
||||
|
||||
const scaleColumns = [
|
||||
{
|
||||
title: '品类',
|
||||
dataIndex: 'displayName',
|
||||
width: 200,
|
||||
fixed: 'left',
|
||||
filters: SCALE_TAGS.map((tag) => ({ text: tag, value: tag })),
|
||||
onFilter: (value, record) => record.scaleTag === value,
|
||||
render: (value, record) => (
|
||||
<div className="pts-category-cell">
|
||||
<div className="pts-category-cell__name">{value || record.category}</div>
|
||||
{/* 展示 jst_sku.category 的完整值,便于核对品类名映射 */}
|
||||
{record.category && record.category !== value ? (
|
||||
<div className="pts-category-cell__db">{record.category}</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '规模标签',
|
||||
dataIndex: 'scaleTag',
|
||||
width: 110,
|
||||
render: (value) => <Tag color={SCALE_TAG_COLOR[value]}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '业绩起点',
|
||||
dataIndex: 'performanceMin',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (value, record) => (
|
||||
<InputNumber
|
||||
className="pts-number"
|
||||
value={value}
|
||||
min={0}
|
||||
precision={0}
|
||||
step={1000}
|
||||
onChange={(next) => patchScaleRow(scaleRowKey(record), 'performanceMin', next)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日销起点',
|
||||
dataIndex: 'salesMin',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
// 日销存在 0.5 这类合法小数(低动销款),不能用 precision=0 强制取整
|
||||
render: (value, record) => (
|
||||
<InputNumber
|
||||
className="pts-number"
|
||||
value={value}
|
||||
min={0}
|
||||
precision={2}
|
||||
step={0.5}
|
||||
onChange={(next) => patchScaleRow(scaleRowKey(record), 'salesMin', next)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '判定优先级',
|
||||
dataIndex: 'priority',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => numText(value),
|
||||
},
|
||||
];
|
||||
|
||||
const profitColumns = [
|
||||
{ title: '利润标签', dataIndex: 'levelCode', width: 120 },
|
||||
{
|
||||
title: '营业利润率上限',
|
||||
dataIndex: 'profitRateMax',
|
||||
width: 160,
|
||||
align: 'right',
|
||||
render: (value, record) => (
|
||||
<InputNumber
|
||||
className="pts-number"
|
||||
value={value}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
precision={4}
|
||||
onChange={(next) => setProfitLevels((rows) => rows.map((row) => (
|
||||
row.levelCode === record.levelCode ? { ...row, profitRateMax: next } : row
|
||||
)))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '说明(展示用,不参与计算)',
|
||||
dataIndex: 'description',
|
||||
render: (value, record) => (
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="如:营业利润率≤0.18"
|
||||
onChange={(event) => setProfitLevels((rows) => rows.map((row) => (
|
||||
row.levelCode === record.levelCode ? { ...row, description: event.target.value } : row
|
||||
)))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// SABC 映射矩阵:每行一个规模标签,每列一个利润标签
|
||||
const matrixColumns = [
|
||||
{
|
||||
title: '规模标签 \ 利润标签',
|
||||
dataIndex: 'scaleTag',
|
||||
width: 150,
|
||||
fixed: 'left',
|
||||
render: (value) => <Tag color={SCALE_TAG_COLOR[value]}>{value}</Tag>,
|
||||
},
|
||||
...PROFIT_LEVELS.map((level) => ({
|
||||
title: level,
|
||||
dataIndex: level,
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Select
|
||||
className="pts-sabc-select"
|
||||
value={sabcValueOf(record.scaleTag, level)}
|
||||
placeholder="--"
|
||||
options={SABC_TAGS.map((tag) => ({ value: tag, label: tag }))}
|
||||
onChange={(value) => patchSabcCell(record.scaleTag, level, value)}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
];
|
||||
|
||||
const matrixRows = SCALE_TAG_ORDER.map((scaleTag) => {
|
||||
const row = { key: scaleTag, scaleTag };
|
||||
PROFIT_LEVELS.forEach((level) => { row[level] = scaleTag; });
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pts-page">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="标签配置保存在本项目独立系统数据库"
|
||||
description="规模标签阈值按「分类」分别设置;判定时区间取左闭右开 [起点, 终点),业绩与日销需同时命中,按优先级升序命中即停,全部未命中则判为「滞销款」。保存后标签不会自动重算,需等待后续数据刷新。"
|
||||
/>
|
||||
|
||||
<SycmCard
|
||||
title="规模标签阈值"
|
||||
extra={<Typography.Text type="secondary">{`共 ${filteredScaleRows.length} / ${scaleThresholds.length} 行`}</Typography.Text>}
|
||||
>
|
||||
<Space wrap size={[12, 12]} className="pts-filter-bar">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索分类"
|
||||
value={categoryFilter}
|
||||
onChange={(event) => setCategoryFilter(event.target.value)}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="规模标签"
|
||||
value={scaleTagFilter}
|
||||
options={SCALE_TAGS.map((tag) => ({ value: tag, label: tag }))}
|
||||
onChange={setScaleTagFilter}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadConfig} loading={loading}>重新加载</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>保存配置</Button>
|
||||
</Space>
|
||||
<Spin spinning={loading} tip="正在加载标签配置…">
|
||||
<Table
|
||||
className="pts-table"
|
||||
rowKey={scaleRowKey}
|
||||
size="small"
|
||||
columns={scaleColumns}
|
||||
dataSource={filteredScaleRows}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 行` }}
|
||||
scroll={{ x: 760 }}
|
||||
/>
|
||||
</Spin>
|
||||
</SycmCard>
|
||||
|
||||
<SycmCard title="利润标签阈值">
|
||||
<Table
|
||||
className="pts-table"
|
||||
rowKey="levelCode"
|
||||
size="small"
|
||||
columns={profitColumns}
|
||||
dataSource={profitLevels}
|
||||
pagination={false}
|
||||
/>
|
||||
</SycmCard>
|
||||
|
||||
<SycmCard
|
||||
title="SABC 映射"
|
||||
extra={<Typography.Text type="secondary">规模标签 × 利润标签 → 公司商品标签</Typography.Text>}
|
||||
>
|
||||
<Table
|
||||
className="pts-table pts-matrix"
|
||||
rowKey="key"
|
||||
size="small"
|
||||
columns={matrixColumns}
|
||||
dataSource={matrixRows}
|
||||
pagination={false}
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
<Space wrap size={[8, 8]} className="pts-legend">
|
||||
<Typography.Text type="secondary">取值说明:</Typography.Text>
|
||||
{SABC_TAGS.map((tag) => (
|
||||
<Tag key={tag} color={SABC_COLOR[tag]}>{tag}</Tag>
|
||||
))}
|
||||
<Typography.Text type="secondary">(S 最优,D 最差;D 档为本次评审确认保留)</Typography.Text>
|
||||
</Space>
|
||||
</SycmCard>
|
||||
|
||||
<SycmCard
|
||||
title="不参与评级的品类"
|
||||
extra={<Typography.Text type="secondary">{`已排除 ${excludedCategories.length} 个`}</Typography.Text>}
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="这里的品类不会被判定规模标签,也不会出现在商品监控的标签统计中"
|
||||
description="与「未配置」不同:未配置是还没设置阈值(商品会显示为未评级、需要补配置);这里是有意排除,属于既定业务口径。改动同样不会自动触发重算。"
|
||||
/>
|
||||
<Select
|
||||
mode="tags"
|
||||
className="pts-exclude-select"
|
||||
placeholder="输入或粘贴 jst_sku.category 的完整值后回车,如:其他 - 配件"
|
||||
value={excludedCategories}
|
||||
onChange={(value) => setExcludedCategories(value)}
|
||||
options={dbCategoryOptions.map((value) => ({ value, label: value }))}
|
||||
tokenSeparators={[',', ',', '\n']}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
当前库中「其他 -」开头的非家具品类还有:其他 - 赠品、其他 - 福利品、其他 - 虚拟产品,
|
||||
如果它们也不评级,一并加进上面的列表。
|
||||
</Typography.Paragraph>
|
||||
</SycmCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductTagSettings;
|
||||
@ -9,7 +9,7 @@ import {
|
||||
SaveOutlined, ReloadOutlined, ApiOutlined, AimOutlined,
|
||||
EditOutlined, DeleteOutlined, PlusOutlined, GlobalOutlined,
|
||||
UploadOutlined, PictureOutlined, FieldTimeOutlined, CloudUploadOutlined, DownloadOutlined,
|
||||
RobotOutlined, KeyOutlined, TagsOutlined
|
||||
RobotOutlined, KeyOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { SycmCard, SycmPage } from '../../components/Common';
|
||||
import { getRedisConfig, updateRedisConfig, switchRedisRuntimeProfile, testRedisConfig, getWorkOrderConfig, updateWorkOrderConfig, testWorkOrderConfig, getSystemConfig, updateSystemConfig, uploadLogo, startBackfill, getBackfillStatus, getS3Config, updateS3Config, testS3Config, downloadProductCodeImportTemplate, downloadProductCodeBatchImportTemplate, importProductCodeWorkbook, deleteProductCodeItems, deleteSelectedProductCodeItems } from '../../api/admin';
|
||||
@ -24,8 +24,6 @@ import SidebarSettings from './SidebarSettings';
|
||||
import DatabaseSettings from './DatabaseSettings';
|
||||
import ApiKeySettings from './ApiKeySettings';
|
||||
import SelectionFissionAiSettings from './SelectionFissionAiSettings';
|
||||
import ProductTagSettings from './ProductTagSettings';
|
||||
import DepartmentTagSettings, { DepartmentOwnerSettings } from './DepartmentTagSettings';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@ -33,7 +31,7 @@ const { Option } = Select;
|
||||
const SETTINGS_TAB_KEYS = new Set([
|
||||
'site', 'sidebar', 'auth', 'databases', 'redis', 'workOrderSystem', 'apiKeys',
|
||||
'selectionFissionAi', 'alibaba1688ImageSearch',
|
||||
'productCodeImport', 'kpiTargets', 'productTags', 'backfill', 'user', 'dept',
|
||||
'productCodeImport', 'kpiTargets', 'backfill', 'user', 'dept',
|
||||
'permission', 's3',
|
||||
]);
|
||||
|
||||
@ -1800,21 +1798,6 @@ const Settings = () => {
|
||||
label: <span className="settings-tab-label"><AimOutlined /> 目标值配置</span>,
|
||||
content: <KpiTargetsSettings />
|
||||
},
|
||||
{
|
||||
key: 'productTags',
|
||||
label: <span className="settings-tab-label"><TagsOutlined /> 商品标签配置</span>,
|
||||
content: <ProductTagSettings />
|
||||
},
|
||||
{
|
||||
key: 'departmentTags',
|
||||
label: <span className="settings-tab-label"><TagsOutlined /> 部门商品标签配置</span>,
|
||||
content: <DepartmentTagSettings />
|
||||
},
|
||||
{
|
||||
key: 'departmentOwners',
|
||||
label: <span className="settings-tab-label"><TeamOutlined /> 部门SKU负责人配置</span>,
|
||||
content: <DepartmentOwnerSettings />
|
||||
},
|
||||
{
|
||||
key: 'backfill',
|
||||
label: <span className="settings-tab-label"><FieldTimeOutlined /> {'\u6570\u636e\u56de\u6eaf'}</span>,
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
/* 商品标签配置(系统设置 → 商品标签配置)
|
||||
* 全部使用 design-system 的 --sycm-* token,不引入新色值
|
||||
*/
|
||||
|
||||
.pts-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
padding-bottom: var(--sycm-page-section-gap);
|
||||
}
|
||||
|
||||
.pts-filter-bar {
|
||||
margin-bottom: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
.pts-filter-bar .ant-input-affix-wrapper,
|
||||
.pts-filter-bar .ant-select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 表格:与 OpsBIncentiveBoard 保持一致的视觉 */
|
||||
.pts-table .ant-table {
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.pts-table .ant-table-thead > tr > th {
|
||||
padding: var(--sycm-table-header-padding-y) var(--sycm-table-header-padding-x);
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
background: var(--sycm-bg-table-head);
|
||||
}
|
||||
|
||||
.pts-table .ant-table-tbody > tr > td {
|
||||
padding: var(--sycm-table-cell-padding-y) var(--sycm-table-cell-padding-x);
|
||||
}
|
||||
|
||||
.pts-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--sycm-bg-table-hover);
|
||||
}
|
||||
|
||||
.pts-table .ant-table-placeholder .ant-table-cell {
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
/* 阈值输入框:去掉数字输入的上下箭头,避免表格里视觉噪声 */
|
||||
.pts-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pts-number input {
|
||||
text-align: right;
|
||||
font-family: var(--sycm-font-family-number);
|
||||
}
|
||||
|
||||
.pts-number .ant-input-number-handler-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* SABC 映射矩阵 */
|
||||
.pts-matrix .ant-table-tbody > tr > td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pts-sabc-select {
|
||||
width: 100%;
|
||||
min-width: 68px;
|
||||
}
|
||||
|
||||
.pts-sabc-select .ant-select-selection-item {
|
||||
font-family: var(--sycm-font-family-number);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.pts-legend {
|
||||
margin-top: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
/* 品类单元格:主行显示配置短名,次行显示 jst_sku.category 完整值 */
|
||||
.pts-category-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.pts-category-cell__name {
|
||||
color: var(--sycm-text-primary);
|
||||
}
|
||||
|
||||
.pts-category-cell__db {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.pts-exclude-select {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.pts-filter-bar .ant-input-affix-wrapper,
|
||||
.pts-filter-bar .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
@ -9,14 +9,9 @@ const Setup = lazy(() => import('../pages/Setup'));
|
||||
const Settings = lazy(() => import('../pages/Settings'));
|
||||
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
|
||||
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
|
||||
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
|
||||
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
|
||||
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
|
||||
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
|
||||
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
|
||||
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
|
||||
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
|
||||
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||
|
||||
const lazyLoadingElement = <PageLoadingFallback />;
|
||||
|
||||
@ -69,38 +64,10 @@ const router = createBrowserRouter([
|
||||
path: 'planning',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-events',
|
||||
element: <Navigate to="/planning/operation/marketing-events/activity-products" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-events/activity-products',
|
||||
element: renderLazyComponent(MarketingEvents),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-tools',
|
||||
element: <Navigate to="/planning/operation/marketing-events" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-information',
|
||||
element: renderLazyComponent(ProductInformation),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-analysis',
|
||||
element: renderLazyComponent(ProductAnalysis),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/promotion-module',
|
||||
element: renderLazyComponent(PromotionModule),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/ops-b-incentive',
|
||||
element: renderLazyComponent(OpsBIncentiveBoard),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-monitoring',
|
||||
element: renderLazyComponent(ProductMonitoring),
|
||||
},
|
||||
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
|
||||
|
||||
@ -13,10 +13,15 @@ test('legacy saved menus cannot resurrect retired features and keep business cus
|
||||
{ key: 'desktop-client-releases', routeKey: 'desktop-client-releases' },
|
||||
] },
|
||||
{ key: 'product-monitoring', routeKey: 'product-monitoring', label: '我的商品监控', order: 1 },
|
||||
{ key: 'ops-b-incentive', routeKey: 'ops-b-incentive' },
|
||||
{ key: 'marketing-management', children: [{ key: 'marketing-events', routeKey: 'marketing-events' }] },
|
||||
{ key: 'product-management', children: [{ key: 'product-analysis', routeKey: 'product-analysis' }] },
|
||||
] }));
|
||||
const entries = result.entries.flatMap((entry) => [entry, ...(entry.children || [])]);
|
||||
assert.ok(entries.every((entry) => !['market-insight', 'agent', 'ai-skill-tags', 'store-management', 'operation-tools', 'desktop-client-releases'].includes(entry.key)));
|
||||
assert.equal(entries.find((entry) => entry.key === 'product-monitoring').label, '我的商品监控');
|
||||
assert.ok(entries.every((entry) => ![
|
||||
'market-insight', 'agent', 'ai-skill-tags', 'store-management', 'operation-tools',
|
||||
'desktop-client-releases', 'product-monitoring', 'ops-b-incentive',
|
||||
'marketing-management', 'marketing-events', 'product-management', 'product-analysis',
|
||||
].includes(entry.key)));
|
||||
assert.ok(entries.some((entry) => entry.key === 'settings'));
|
||||
assert.ok(entries.some((entry) => entry.key === 'product-analysis'));
|
||||
});
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
-- 商品标签配置:规模标签阈值 / 利润标签阈值 / SABC 映射
|
||||
-- 目标库:系统库 shujuku3_system(MySQL / MariaDB)
|
||||
-- 创建日期:2026-09-11
|
||||
--
|
||||
-- 说明:
|
||||
-- 这三张表也会由 backend/src/services/localSchema.js 的 ensureProductTagConfigSchema()
|
||||
-- 在服务启动时自动创建(CREATE TABLE IF NOT EXISTS)。本文件供 DBA 手工建表使用,
|
||||
-- 因为开发环境使用的 shujuku3_external 账号没有 DDL 权限。
|
||||
--
|
||||
-- 执行:mysql -h <host> -P <port> -u <ddl_user> -p shujuku3_system < 20260911_create_product_tag_config_system_mysql.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_scale_thresholds (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌',
|
||||
display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)',
|
||||
scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款',
|
||||
performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)',
|
||||
performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)',
|
||||
sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)',
|
||||
sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_category_tag (category, scale_tag),
|
||||
KEY idx_category_priority (category, enabled, priority),
|
||||
KEY idx_display_name (display_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_profit_levels (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level_code VARCHAR(16) NOT NULL COMMENT '一级~五级',
|
||||
profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)',
|
||||
description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_level_code (level_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_sabc_mappings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scale_tag VARCHAR(32) NOT NULL,
|
||||
profit_level VARCHAR(16) NOT NULL,
|
||||
sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scale_profit (scale_tag, profit_level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射';
|
||||
|
||||
-- ============================================================
|
||||
-- 初始数据
|
||||
-- 生成方式:node backend/scripts/seed_product_tag_config_from_xlsx.js --print-sql
|
||||
-- 数据来源:《公司SABC标签配置.xlsx》
|
||||
-- - 规模阈值 148 行(37 品类 × 4 档;品类为 '-' 的行已忽略;浮点阈值已取整)
|
||||
-- - 利润标签 5 行
|
||||
-- - SABC 映射 25 行
|
||||
-- ============================================================
|
||||
|
||||
-- 利润标签(区块② 的 J 列)
|
||||
INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort) VALUES
|
||||
('一级', 0.1800, '营业利润率≤0.18', 1),
|
||||
('二级', 0.1600, '营业利润率≤0.16', 2),
|
||||
('三级', 0.1200, '营业利润率≤0.12', 3),
|
||||
('四级', 0.0800, '营业利润率≤0.08', 4),
|
||||
('五级', 0.0800, '营业利润率≤0.08', 5)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
profit_rate_max = VALUES(profit_rate_max),
|
||||
description = VALUES(description),
|
||||
sort = VALUES(sort);
|
||||
|
||||
-- SABC 映射(区块② 的 H/L 列,5×5 矩阵)
|
||||
INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort) VALUES
|
||||
('爆款', '一级', 'S', 1),
|
||||
('爆款', '二级', 'S', 2),
|
||||
('爆款', '三级', 'S', 3),
|
||||
('爆款', '四级', 'A', 4),
|
||||
('爆款', '五级', 'B', 5),
|
||||
('热卖款', '一级', 'S', 6),
|
||||
('热卖款', '二级', 'A', 7),
|
||||
('热卖款', '三级', 'A', 8),
|
||||
('热卖款', '四级', 'A', 9),
|
||||
('热卖款', '五级', 'B', 10),
|
||||
('动销款', '一级', 'A', 11),
|
||||
('动销款', '二级', 'B', 12),
|
||||
('动销款', '三级', 'B', 13),
|
||||
('动销款', '四级', 'B', 14),
|
||||
('动销款', '五级', 'C', 15),
|
||||
('低动销款', '一级', 'B', 16),
|
||||
('低动销款', '二级', 'C', 17),
|
||||
('低动销款', '三级', 'C', 18),
|
||||
('低动销款', '四级', 'C', 19),
|
||||
('低动销款', '五级', 'D', 20),
|
||||
('滞销款', '一级', 'C', 21),
|
||||
('滞销款', '二级', 'D', 22),
|
||||
('滞销款', '三级', 'D', 23),
|
||||
('滞销款', '四级', 'D', 24),
|
||||
('滞销款', '五级', 'D', 25)
|
||||
ON DUPLICATE KEY UPDATE sabc_tag = VALUES(sabc_tag), sort = VALUES(sort);
|
||||
|
||||
-- 规模标签阈值(148 行,见单独生成的文件)
|
||||
-- 因为行数较多,请使用:
|
||||
-- node backend/scripts/seed_product_tag_config_from_xlsx.js
|
||||
-- 直接把 xlsx 导入数据库,或加 --print-sql 生成 INSERT 语句后手工执行。
|
||||
@ -1,7 +0,0 @@
|
||||
-- 商品分析:用户个性化阈值表(系统库,MariaDB/MySQL)
|
||||
CREATE TABLE IF NOT EXISTS product_analysis_user_prefs (
|
||||
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
|
||||
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置';
|
||||
Loading…
x
Reference in New Issue
Block a user