feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料 #6

Merged
yanhui merged 1 commits from yanhui/cangku:codex/yanhui-operation-modules-001 into main 2026-09-16 13:49:36 +08:00
66 changed files with 13379 additions and 483 deletions

4
.gitignore vendored
View File

@ -58,3 +58,7 @@ tmall-board-speed-check.json
.remote-access.backup-*/
.workbuddy/
**/node_modules
# Local verification artifacts
/outputs/
.git-status.txt

View File

@ -2,7 +2,7 @@
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
保留仪表盘、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
@ -27,7 +27,7 @@ npm --prefix frontend run check
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。迁移仅作用于实际配置给此实例的系统数据库。
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。
## 参与修改

View File

@ -9,7 +9,8 @@
"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"
"alert:check": "node src/services/alertChecker.js",
"product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js"
},
"dependencies": {
"@aws-sdk/client-s3": "3.975.0",

View File

@ -0,0 +1,404 @@
#!/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;
});

View File

@ -0,0 +1,218 @@
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,
};

View File

@ -0,0 +1,55 @@
-- 商品监控配套:部门商品标签规则 + 部门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负责人配置';

View File

@ -1,364 +0,0 @@
'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;

View File

@ -0,0 +1,268 @@
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;

40
backend/src/api/mkhBom.js Normal file
View File

@ -0,0 +1,40 @@
'use strict';
const express = require('express');
const {
getMkhBomList,
getMkhBomDetail,
} = require('../services/mkhBomService');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[MkhBom]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
router.get('/list', async (req, res) => {
try {
res.json({ success: true, data: await getMkhBomList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 资料列表失败');
}
});
// 单个 MKH 组合商品的 BOM 配件明细(打印用)
router.get('/detail', async (req, res) => {
try {
const skuId = req.query?.skuId || req.query?.comboSkuId;
res.json({ success: true, data: await getMkhBomDetail(skuId) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 明细失败');
}
});
module.exports = router;

View File

@ -0,0 +1,39 @@
'use strict';
const express = require('express');
const {
getModularStockList,
getModularStockFilters,
} = require('../services/modularStockMonitorService');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[ModularStockMonitor]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// 配件库存监控明细列表(默认仅「简尚-崔桥工厂半成品包装项目仓」)
router.get('/list', async (req, res) => {
try {
res.json({ success: true, data: await getModularStockList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载配件库存监控列表失败');
}
});
// 筛选下拉选项(品类 / 预警等级)
router.get('/filters', async (_req, res) => {
try {
res.json({ success: true, data: await getModularStockFilters() });
} catch (error) {
sendError(res, error, '加载配件库存监控筛选选项失败');
}
});
module.exports = router;

View File

@ -1,9 +1,20 @@
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.
@ -29,4 +40,43 @@ 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

View File

@ -0,0 +1,73 @@
'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;

View File

@ -0,0 +1,417 @@
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;

View File

@ -0,0 +1,169 @@
/**
* 产品资料系统数据库连接模块(PostgreSQL)
*
* 数据来源:系统库 database_configs 表中 name = 'product_information_database' 的配置。
* 典型配置:postgres / 218.93.9.38:25432 / furniture3d_prod / external_full_reader / ssl=1
*
* 该库用于 MKH-BOM 资料页面的商品资料匹配:
* external.intake_sku_items_ready_v1
* —— 真实可用的 SKU 明细视图(约 4.6 万行)
* —— source_sku_id 为聚水潭原始 SKU 编码(即业务库 BOM 的配件编码)
* —— sku_code 为商品编码,barcode 为条码
* external.warehouse_locations_v1 —— 库位主数据(当前仅 1 行:A-01 成品区)
*
* 说明:external.product_bom_v1 / product_skus_v1 当前为空视图,不可用。
*/
const { Pool } = require('pg');
const database = require('./database');
const CONFIG_NAME = 'product_information_database';
const READY_VIEW = 'external.intake_sku_items_ready_v1';
const LOCATION_VIEW = 'external.warehouse_locations_v1';
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const POOL_MAX = parsePositiveInt(process.env.PRODUCT_DB_POOL_MAX, 4);
let pool = null;
let cachedSignature = '';
const truthy = (value) => (
value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1'
);
async function loadStoredConfig() {
const rows = await database.local.query(
`SELECT id, host, port, database_name, username, password, \`ssl\`, driver, status
FROM database_configs WHERE name = ? LIMIT 1`,
[CONFIG_NAME]
);
const config = rows[0];
if (!config) {
const error = new Error('产品资料系统数据库尚未配置,请在「系统设置 - 数据库配置」中完成配置');
error.statusCode = 503;
error.code = 'PRODUCT_DB_NOT_CONFIGURED';
throw error;
}
if (String(config.driver || '').toLowerCase() !== 'postgres') {
const error = new Error('产品资料系统数据库需要选择 PostgreSQL 类型');
error.statusCode = 503;
error.code = 'PRODUCT_DB_DRIVER_MISMATCH';
throw error;
}
return {
id: config.id,
host: String(config.host || '').trim(),
port: Number(config.port) || 5432,
database: String(config.database_name || '').trim(),
user: String(config.username || '').trim(),
password: config.password || '',
ssl: truthy(config.ssl),
};
}
const buildSignature = (config) => [
config.id,
config.host,
config.port,
config.database,
config.user,
config.ssl ? 'ssl' : 'nossl',
].join('::');
async function getPool() {
const config = await loadStoredConfig();
const signature = buildSignature(config);
if (pool && signature === cachedSignature) {
return pool;
}
if (pool) {
await pool.end().catch(() => {});
pool = null;
}
pool = new Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl ? { rejectUnauthorized: false } : false,
max: POOL_MAX,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 20000,
query_timeout: 180000,
statement_timeout: 180000,
});
pool.on('error', (error) => {
console.warn('[ProductDB] 连接池异常:', error.message);
});
cachedSignature = signature;
return pool;
}
/**
* 执行查询,返回行数组。
* @param {string} sqlText PostgreSQL 语句(使用 $1/$2 占位)
* @param {Array} params 参数数组
*/
async function query(sqlText, params = []) {
const maxAttempts = 2;
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const activePool = await getPool();
const result = await activePool.query(sqlText, params);
return result.rows;
} catch (error) {
lastError = error;
if (error.code === 'PRODUCT_DB_NOT_CONFIGURED' || error.code === 'PRODUCT_DB_DRIVER_MISMATCH') {
throw error;
}
if (attempt < maxAttempts) {
console.warn(`[ProductDB] 查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`);
await reset();
}
}
}
throw lastError;
}
async function reset() {
if (pool) {
await pool.end().catch(() => {});
}
pool = null;
cachedSignature = '';
}
async function test() {
try {
const rows = await query('SELECT 1 AS test');
return { success: true, message: '产品资料系统数据库连接成功', data: rows };
} catch (error) {
return { success: false, message: `连接失败: ${error.message}` };
}
}
module.exports = {
query,
test,
reset,
getPool,
CONFIG_NAME,
READY_VIEW,
LOCATION_VIEW,
};

View File

@ -14,12 +14,31 @@ 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: 'modular_stock_monitor',
pageName: '模块化库存监控 · 配件库存监控',
path: '/planning/operation/modular-stock-monitor',
}),
buildPlanningPage({
pageKey: 'mkh_bom',
pageName: '模块化库存监控 · MKH-BOM资料',
path: '/planning/operation/mkh-bom-data',
}),
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
{
pageKey: 'settings',

View File

@ -29,7 +29,12 @@ const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
const channelRoutes = require('./api/channel');
const analysisRoutes = require('./api/analysis');
const productCodeBoardRoutes = require('./api/productCodeBoard');
const dashboardRoutes = require('./api/dashboard');
const productAnalysisRoutes = require('./api/productAnalysis');
const marketingEventsRoutes = require('./api/marketingEvents');
const promotionModuleRoutes = require('./api/promotionModule');
const productMonitoringRoutes = require('./api/productMonitoring');
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
const mkhBomRoutes = require('./api/mkhBom');
const database = require('./config/database');
const cloudDb = require('./config/cloudDatabase');
const jianSunDb = require('./config/jianSunDatabase');
@ -40,6 +45,8 @@ 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');
@ -123,6 +130,7 @@ 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;
@ -640,9 +648,14 @@ 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/dashboard', authMiddleware, dashboardRoutes);
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
// =====================
// 数据分析 API(公开访问,可根据需要添加认证)
@ -680,9 +693,9 @@ async function startServer(options = {}) {
console.log('='.repeat(50));
console.log('🚀 后端服务已启动');
console.log(`📡 服务地址: http://localhost:4000`);
console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
console.log(cubeMode === 'cube'
? '📊 Cube.js API: http://localhost:4000/cubejs-api'
? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api`
: '📊 Cube.js API: 已降级不可用,业务 API 继续运行');
// 测试数据库连接
@ -708,6 +721,8 @@ async function startServer(options = {}) {
startPddSkuDimensionSnapshotSchedulers();
startPlanningMayProductSnapshotSchedulers();
startPlanningDingWorkflowSyncSchedulers();
startOpsBIncentiveSnapshotSchedulers();
startGoodsFirstSeenScheduler();
startAlibaba1688ImageSearchExpiryScheduler();
// Keep the first selection-pool page request from paying the remote schema

View File

@ -0,0 +1,164 @@
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 };

View File

@ -0,0 +1,31 @@
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 };

View File

@ -77,6 +77,8 @@ 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'),
};
}

View File

@ -7,6 +7,11 @@ 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'"],
@ -113,6 +118,18 @@ 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
@ -238,10 +255,67 @@ 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',
@ -877,7 +951,11 @@ 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();

View File

@ -0,0 +1,417 @@
'use strict';
// 模块化库存监控 —— MKH-BOM 资料
//
// 页面结构(与业务确认的模板):MKH商品编码 | 配件明细 | 数量 | 配件仓位
//
// ── 数据口径 ──────────────────────────────────────────────────────
//
// ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt)
// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品' (约 4436 条)
// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198
//
// ② 配件明细(BOM)—— 产品资料系统数据库(PostgreSQL / furniture3d_prod)
// public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails'
// · 每一项形如 { code, name, role/assetType, barcode, quantity }
// · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 },
// { barcode: DNZ038401N1440V01, quantity: 1, role: 组件 }]
// · 匹配:编码去横线/空格转大写后比对;精确 SKU 匹配不到时退化到 SPU 前缀匹配
// (业务库 MKHDNZ0384260600002 ↔ 资料库 MKH-DNZ0384260600001)
// · 资料库里已维护 BOM、但业务库 jst_sku 里没有对应成品的模块化商品,
// 会以 source='product_db' 追加到列表最前,保证已维护的数据一定可见。
//
// ⚠️ 该库另有 external.product_bom_v1 标准视图,但它依赖 Product / ProductSKU /
// Panel 三张整合表,这三张表当前均为 0 行,因此视图恒为空、不要用。
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
// (无 SKU 条码),也不能直接当配件 BOM。
//
// ③ 配件仓位 —— 资料库 external.warehouse_locations_v1(库位主数据,当前仅 A-01);
// 业务库 jst_sku.bin / other_bin 全库为空,故多数配件显示「未维护」。
//
// ④ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。
const cloudDatabase = require('../config/cloudDatabase');
const productDb = require('../config/productInformationDatabase');
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const MAX_EXTRA_LOOKUP = 300;
// MKH 商品编码 = 模块化成品
const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品'`;
const toText = (value) => {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text === '' ? null : text;
};
const toNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
// 编码归一化:去横线/空格 + 大写,用于跨库匹配
const normalizeCode = (value) => {
const text = toText(value);
return text ? text.toUpperCase().replace(/[-\s]/g, '') : null;
};
const buildFilters = (query = {}) => {
const conditions = [];
const params = {};
const keyword = toText(query.keyword);
if (keyword) {
params.keyword = `%${keyword}%`;
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
}
return { conditions, params };
};
/**
* 把 componentDetails 原始 JSON 解析成统一结构。
*/
function parseComponents(raw) {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => {
if (!item || typeof item !== 'object') return null;
const barcode = toText(item.barcode);
const code = toText(item.code);
const name = toText(item.name) || barcode || code;
return {
barcode: barcode || code,
code,
name,
role: toText(item.role) || toText(item.assetType),
qty: toNumber(item.quantity) ?? 1,
};
})
.filter(Boolean);
}
/**
* 从产品资料系统数据库读取全部「有配件明细」的模块化商品。
* 返回 { bySku: Map, bySpuPrefix: Array, entries: Array }
* 失败时返回空结构并降级(页面照常展示列表,只是配件列为 0)。
*/
async function loadPgBom() {
const empty = { bySku: new Map(), bySpuPrefix: [], entries: [] };
const sql = `
SELECT
p.id AS product_id,
p.spu AS spu,
COALESCE(s->>'skuCode', s->>'barcode') AS sku_code,
s->'componentDetails' AS components
FROM public."IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s
WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`;
let rows = [];
try {
rows = await productDb.query(sql);
} catch (error) {
console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message);
return empty;
}
// 商品名称(资料库档案表,取不到不影响主流程)
const nameMap = new Map();
try {
const nameRows = await productDb.query(
`SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`);
nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName)));
} catch (error) {
console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message);
}
const bySku = new Map();
const bySpuPrefix = [];
const entries = [];
rows.forEach((row) => {
const components = parseComponents(row.components);
if (components.length === 0) return;
const skuCode = toText(row.sku_code);
const spu = toText(row.spu);
const skuKey = normalizeCode(skuCode);
if (skuKey) bySku.set(skuKey, components);
const spuKey = normalizeCode(spu);
if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu });
entries.push({
skuCode,
spu,
productName: nameMap.get(toText(row.product_id)) || spu || skuCode,
components,
});
});
// 前缀长优先,避免短前缀抢先命中
bySpuPrefix.sort((a, b) => b.prefix.length - a.prefix.length);
return { bySku, bySpuPrefix, entries };
}
/**
* 按编码匹配配件:先精确 SKU,再退化到 SPU 前缀。
*/
function matchComponents(bom, skuId) {
const key = normalizeCode(skuId);
if (!key) return null;
const exact = bom.bySku.get(key);
if (exact) return { components: exact, matchType: 'sku' };
const hit = bom.bySpuPrefix.find((entry) => key.startsWith(entry.prefix));
if (hit) return { components: hit.components, matchType: 'spu' };
return null;
}
const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0);
/**
* 找出「资料库里已维护 BOM、但业务库列表里没有」的模块化商品,
* 作为追加行放在列表最前面,保证已维护的数据一定能在页面上看到。
*/
async function buildExtraRows(bom) {
if (!bom.entries.length) return [];
if (bom.entries.length > MAX_EXTRA_LOOKUP) return [];
const candidates = [];
bom.entries.forEach((entry) => {
[entry.skuCode, normalizeCode(entry.skuCode)].forEach((code) => {
if (code && !candidates.includes(code)) candidates.push(code);
});
});
if (!candidates.length) return [];
const params = {};
const placeholders = candidates.map((code, index) => {
params[`c${index}`] = code;
return `@c${index}`;
});
let existing = new Set();
try {
const rows = await cloudDatabase.query(
`SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.sku_id IN (${placeholders.join(', ')})`,
params,
{ requestTimeout: 120000 }
);
existing = new Set(rows.map((row) => normalizeCode(row.skuId)).filter(Boolean));
} catch (error) {
console.warn('[MkhBom] 追加行去重查询失败,按全部追加处理:', error.message);
}
return bom.entries
.filter((entry) => {
const key = normalizeCode(entry.skuCode);
return key && !existing.has(key);
})
.map((entry) => ({
skuId: entry.skuCode || entry.spu,
productName: entry.productName,
category: null,
material: null,
accessoryCount: entry.components.length,
accessoryQtySum: sumQty(entry.components),
bomConfigured: '已配置',
bomMatchType: 'sku',
source: 'product_db',
}));
}
/**
* 列表:MKH 模块化成品(业务库)+ 配件种类/数量(资料库 BOM)
*/
async function getMkhBomList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const { conditions, params } = buildFilters(query);
const whereSql = conditions.length
? `FROM dbo.jst_sku s WHERE ${MKH_WHERE} AND ${conditions.join(' AND ')}`
: `FROM dbo.jst_sku s WHERE ${MKH_WHERE}`;
const sortField = String(query.sortField || 'skuId');
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`;
const bom = await loadPgBom();
const extraRows = await buildExtraRows(bom);
const extraCount = extraRows.length;
// 追加行占据全局前 extraCount 个位置,业务库行顺延
const offset = (page - 1) * pageSize;
const extraStart = Math.min(offset, extraCount);
const extraTake = Math.max(0, Math.min(pageSize, extraCount - extraStart));
const businessOffset = Math.max(0, offset - extraCount);
const businessTake = pageSize - extraTake;
const pageExtras = extraRows.slice(extraStart, extraStart + extraTake);
let businessRows = [];
if (businessTake > 0) {
businessRows = await cloudDatabase.query(
`SELECT
s.sku_id AS skuId,
s.name AS productName,
s.category AS category,
s.other_10 AS material
${whereSql}
ORDER BY ${orderSql}
OFFSET ${businessOffset} ROWS FETCH NEXT ${businessTake} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
);
}
const [totalRow] = await cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const mapped = businessRows.map((row) => {
const skuId = toText(row.skuId);
const matched = matchComponents(bom, skuId);
const components = matched?.components || [];
return {
skuId,
productName: toText(row.productName),
category: toText(row.category),
material: toText(row.material),
accessoryCount: components.length,
accessoryQtySum: sumQty(components),
bomConfigured: components.length > 0 ? '已配置' : '未配置',
bomMatchType: matched?.matchType || null,
source: 'business_db',
};
});
const rows = [...pageExtras, ...mapped];
return {
rows,
total: Number(totalRow?.total || 0) + extraCount,
bomRowCount: bom.entries.length,
bomExtraCount: extraCount,
bomReady: bom.entries.length > 0,
page,
pageSize,
configuredInPage: rows.filter((row) => row.accessoryCount > 0).length,
};
}
/**
* 明细:单个 MKH 成品的配件清单
* 结构按业务模板:配件明细 | 数量 | 配件仓位
*/
async function getMkhBomDetail(mkhSkuId) {
const skuId = toText(mkhSkuId);
if (!skuId) {
const error = new Error('缺少 MKH 商品编码');
error.statusCode = 400;
throw error;
}
const comboRows = await cloudDatabase.query(
`SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category
FROM dbo.jst_sku s
WHERE s.sku_id = @mkhSkuId`,
{ mkhSkuId: skuId },
{ requestTimeout: 120000 }
);
let combo = comboRows[0] ? {
skuId: toText(comboRows[0].skuId),
productName: toText(comboRows[0].productName),
category: toText(comboRows[0].category),
} : null;
const bom = await loadPgBom();
const matched = matchComponents(bom, skuId);
const components = matched?.components || [];
// 业务库里没有该成品(资料库专有编码)时,用资料库条目补齐基础信息
if (!combo) {
const entry = bom.entries.find((item) => normalizeCode(item.skuCode) === normalizeCode(skuId));
if (!entry) {
const error = new Error('未找到该 MKH 商品');
error.statusCode = 404;
throw error;
}
combo = { skuId, productName: entry.productName, category: null };
}
const codes = components.map((item) => item.barcode).filter(Boolean);
const stockMap = new Map();
const locationMap = new Map();
if (codes.length > 0) {
const placeholders = codes.map((_, index) => `@c${index}`).join(', ');
const codeParams = Object.fromEntries(codes.map((code, index) => [`c${index}`, code]));
try {
const stockRows = await cloudDatabase.query(
`SELECT i.sku_id AS skuId, SUM(i.qty) AS qty
FROM dbo.jst_inventoryInfo i
WHERE i.sku_id IN (${placeholders})
GROUP BY i.sku_id`,
codeParams,
{ requestTimeout: 180000 }
);
stockRows.forEach((row) => stockMap.set(toText(row.skuId), toNumber(row.qty) ?? 0));
} catch (error) {
console.warn('[MkhBom] 配件库存查询降级:', error.message);
}
try {
const locRows = await cloudDatabase.query(
`SELECT sku_id AS skuId,
COALESCE(NULLIF(LTRIM(RTRIM(bin)), N''), NULLIF(LTRIM(RTRIM(other_bin)), N'')) AS location
FROM dbo.jst_sku
WHERE sku_id IN (${placeholders})`,
codeParams,
{ requestTimeout: 120000 }
);
locRows.forEach((row) => locationMap.set(toText(row.skuId), toText(row.location)));
} catch (error) {
console.warn('[MkhBom] 配件仓位查询降级:', error.message);
}
}
const items = components.map((component, index) => ({
index: index + 1,
skuId: component.barcode,
barcode: component.barcode,
skuName: component.name,
role: component.role,
qty: toNumber(component.qty) ?? 0,
location: locationMap.get(component.barcode) || null,
stock: stockMap.get(component.barcode) ?? 0,
}));
return {
skuId: toText(combo.skuId),
productName: toText(combo.productName),
category: toText(combo.category),
items,
bomReady: items.length > 0,
bomMatchType: matched?.matchType || null,
};
}
module.exports = {
getMkhBomList,
getMkhBomDetail,
};

View File

@ -0,0 +1,513 @@
'use strict';
// 模块化库存监控 —— 配件库存监控
//
// 需求来源:《模块化库存监控.xlsx》
// 一级功能:模块化库存监控 二级功能:配件库存监控
// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码
//
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库
// 目标仓库共 794 个 SKU
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
// sevenAvgSale 近7日日均发货量
// jsUseDays 库存可用天数
// safetyDays 安全天数(默认 15)
// suggestStock 建议备货量
// jsStock 监控表口径库存(作为 inventoryInfo 的兜底)
// nearSecondSale 近一日发货量 → 当日出库
//
// ⚠️ 关联键坑(2026-09-12 修正):
// pm_stockMonitor 里同时有 sku 与 sku_id 两列:
// sku = 短款号,如 DNZ040967 —— 用它关联只能命中 34 / 794
// sku_id = 完整 SKU 编码 DNZ040967N1440V01 —— 必须用它关联,命中 794 / 794
// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty
// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794)
//
// ⚠️ 业务库确实无数据源的字段:
// - 仓位 location:rpt.jst_sku.bin / other_bin 全库 557,475 行填充数为 0
const cloudDatabase = require('../config/cloudDatabase');
const productDb = require('../config/productInformationDatabase');
const WAREHOUSE_NAME = '简尚-崔桥工厂半成品包装项目仓';
// rpt.jst_warehouseInfo 中「简尚-崔桥工厂半成品包装项目仓」对应的 wms_co_id
const WAREHOUSE_WMS_CO_ID = 15718196;
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const DEFAULT_SAFETY_DAYS = 15;
// 预警阈值:库存可用天数 <= 7 天即预警
const LOW_STOCK_DAYS_THRESHOLD = 7;
const WARNING_LEVEL = Object.freeze({
OUT_OF_STOCK: '缺货',
WARNING: '预警',
NORMAL: '正常',
});
// 模块化配置状态:
// 「已配置MKH」= 该 SKU 已在「产品资料系统数据库」中建立商品档案
// (external.product_catalog_wide_base_v1 按 barcode 匹配)
// 「未配置MKH」= 产品资料系统数据库中查不到该 SKU 档案
//
// 口径变更说明(2026-09-15,按业务确认):
// 原先用业务库 jst_comboProductItem.src_sku_id 判定「是否被 MKH 组合 BOM 引用」,
// 实测目标仓 794 个 SKU 中只有 32 个命中——因为目标仓(半成品包装仓)与
// MKH 组合 BOM 的配件(159 个 SKU / 14 个款号)是两批基本不重叠的编码集合,
// 按款号(SPU)放宽也只有 3 个款号重叠。
// 业务确认改用「产品资料系统数据库是否有商品档案」作为判定口径,
// 实测目标仓 661/794 命中。
const MKH_STATUS = Object.freeze({
CONFIGURED: '已配置MKH',
UNCONFIGURED: '未配置MKH',
});
// 产品资料覆盖度缓存:避免每次列表请求都回查产品资料系统数据库
const PRODUCT_COVERAGE_TTL_MS = 10 * 60 * 1000;
let productCoverageCache = null;
const SORTABLE_FIELDS = Object.freeze({
skuId: 's.sku_id',
productName: 's.name',
category: 's.category',
realtimeStock: 'realtimeStock',
avgShip7d: 'm.sevenAvgSale',
stockDays: 'm.jsUseDays',
warningLevel: 'warningOrder',
mkhStatus: 'mkhStatus',
location: 'location',
warehouse: 's.other_3',
});
/**
* 「已配置MKH」判定表达式。
* available = true —— 用产品资料库覆盖清单(@productCoveredCsv,逗号分隔的 SKU 编码)
* available = false —— 产品资料库不可用时回退到业务库 MKH BOM 判定,保证页面可用
*/
const buildConfiguredExistsSql = (coverage = {}) => (
coverage.available
? `EXISTS (
SELECT 1 FROM STRING_SPLIT(@productCoveredCsv, N',') pc WHERE pc.value = s.sku_id
)`
: `EXISTS (
SELECT 1 FROM JSDataMiddlePlatform.dbo.jst_comboProductItem x WHERE x.src_sku_id = s.sku_id
)`
);
const buildBaseSql = (coverage = {}) => `
FROM dbo.jst_sku s
OUTER APPLY (
SELECT TOP 1 i.qty
FROM dbo.jst_inventoryInfo i
WHERE i.sku_id = s.sku_id AND i.wms_co_id = @wmsCoId
ORDER BY i.updateTime DESC
) inv
OUTER APPLY (
SELECT TOP 1 m2.sevenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
m2.date, m2.jsStock, m2.nearSecondSale
FROM dbo.pm_stockMonitor m2
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
ORDER BY m2.date DESC
) m
OUTER APPLY (
SELECT CAST(SUM(ISNULL(p.inQty, 0)) AS DECIMAL(18, 2)) AS inQty
FROM dbo.jst_purchaseItem p
WHERE p.sku_id = s.sku_id
AND p.delivery_date >= @monitorDate
AND p.delivery_date < DATEADD(DAY, 1, @monitorDate)
) pin
OUTER APPLY (
SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode
FROM dbo.fys_skuCostPriceUpdate c
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
ORDER BY c.createTime DESC
) cost
-- 模块化配置状态:见文件头部「口径变更说明」
OUTER APPLY (
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql(coverage)} THEN 1 ELSE 0 END AS BIT) AS configured
) cfg
WHERE s.other_3 = @warehouseName`;
const toNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const toText = (value) => {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text === '' ? null : text;
};
const normalizeWarningLevel = (realtimeStock, stockDays) => {
const stock = Number(realtimeStock || 0);
if (stock <= 0) return WARNING_LEVEL.OUT_OF_STOCK;
const days = toNumber(stockDays);
if (days !== null && days <= LOW_STOCK_DAYS_THRESHOLD) return WARNING_LEVEL.WARNING;
return WARNING_LEVEL.NORMAL;
};
const WARNING_ORDER = {
[WARNING_LEVEL.OUT_OF_STOCK]: 0,
[WARNING_LEVEL.WARNING]: 1,
[WARNING_LEVEL.NORMAL]: 2,
};
/**
* 取目标仓最新监控日(pm_stockMonitor 快照日),作为「当日入库 / 当日出库」的统计基准日。
* 业务库没有出入库流水表,只能以监控日为基准做按日汇总。
*/
async function resolveMonitorDate() {
const rows = await cloudDatabase.query(
`SELECT CAST(MAX(date) AS DATE) AS monitorDate
FROM dbo.pm_stockMonitor
WHERE warehouse = @warehouseName`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 120000 }
);
const raw = rows?.[0]?.monitorDate;
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
}
const buildFilters = (query = {}, monitorDate = null, coverage = {}) => {
const conditions = [];
const params = {
wmsCoId: WAREHOUSE_WMS_CO_ID,
warehouseName: WAREHOUSE_NAME,
monitorDate,
};
// 「已配置MKH」判定依赖产品资料库覆盖清单(逗号分隔的 SKU 编码)
if (coverage.available) {
params.productCoveredCsv = coverage.csv || '';
}
const keyword = toText(query.keyword);
if (keyword) {
params.keyword = `%${keyword}%`;
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
}
const category = toText(query.category);
if (category) {
params.category = category;
conditions.push('s.category = @category');
}
const location = toText(query.location);
if (location) {
params.location = location;
conditions.push(`COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) = @location`);
}
const mkhStatus = toText(query.mkhStatus);
if (mkhStatus === MKH_STATUS.CONFIGURED || mkhStatus === MKH_STATUS.UNCONFIGURED) {
params.mkhStatus = mkhStatus;
conditions.push(`CASE WHEN cfg.configured = 1 THEN N'${MKH_STATUS.CONFIGURED}' ELSE N'${MKH_STATUS.UNCONFIGURED}' END = @mkhStatus`);
}
const warningLevel = toText(query.warningLevel);
if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) {
params.warningOrder = WARNING_ORDER[warningLevel];
conditions.push(`
CASE
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END = @warningOrder`);
}
const onlyWarning = String(query.onlyWarning || '').toLowerCase();
if (['1', 'true', 'yes'].includes(onlyWarning)) {
conditions.push(`(
COALESCE(inv.qty, m.jsStock, 0) <= 0
OR (m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD})
)`);
}
return { conditions, params };
};
/**
* 用「产品资料系统数据库」补齐商品图片。
*
* ⚠️ 2026-09-15 实测:目标仓 794 个 SKU 在业务库里图片字段全部为空——
* rpt.jst_sku.pic / pic_big 794/794 为 NULL
* rpt.fys_skuCostPriceUpdate.pictureSkucode 794/794 为 NULL
* 且 rpt.jst_sku 中只有 pic / pic_big 两个图片列,无其他图片源。
* 因此图片统一改从产品资料系统数据库(PostgreSQL)取:
* external.product_catalog_wide_base_v1
* 按 barcode(完整 SKU 编码)匹配,image_url 优先、product_thumbnail_url 兜底
* 实测目标仓 661 条可匹配,其中 481 条带图。
* 查询失败时静默降级,不影响列表主流程。
*/
async function attachProductImages(rows = []) {
const codes = Array.from(new Set(rows.map((row) => toText(row.skuId)).filter(Boolean)));
if (codes.length === 0) {
return rows;
}
const imageMap = new Map();
try {
const imageRows = await productDb.query(
`SELECT barcode, image_url, product_thumbnail_url
FROM external.product_catalog_wide_base_v1
WHERE barcode = ANY($1)`,
[codes]
);
imageRows.forEach((row) => {
const code = toText(row.barcode);
if (!code || imageMap.has(code)) return;
const url = toText(row.image_url) || toText(row.product_thumbnail_url);
if (url) imageMap.set(code, url);
});
} catch (error) {
console.warn('[ModularStock] 读取产品资料库图片失败,降级为业务库图片:', error.message);
}
return rows.map((row) => ({
...row,
productImageUrl: imageMap.get(toText(row.skuId)) || null,
}));
}
/**
* 解析目标仓 SKU 的「产品资料库覆盖清单」,用于「已配置MKH」判定。
* 返回 { available, csv, set, total, coveredCount },结果按 TTL 缓存。
* 产品资料库不可用时 available = false,SQL 侧自动回退到业务库 MKH BOM 判定。
*/
async function resolveProductCoverage() {
const now = Date.now();
if (
productCoverageCache
&& productCoverageCache.warehouse === WAREHOUSE_NAME
&& now - productCoverageCache.at < PRODUCT_COVERAGE_TTL_MS
) {
return productCoverageCache;
}
const skuRows = await cloudDatabase.query(
`SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.other_3 = @warehouseName`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 180000 }
);
const codes = skuRows.map((row) => toText(row.skuId)).filter(Boolean);
let covered = [];
let available = true;
try {
const rows = await productDb.query(
`SELECT DISTINCT barcode
FROM external.product_catalog_wide_base_v1
WHERE barcode = ANY($1)`,
[codes]
);
covered = rows.map((row) => toText(row.barcode)).filter(Boolean);
} catch (error) {
available = false;
console.warn('[ModularStock] 产品资料库覆盖清单查询失败,回退业务库 MKH BOM 判定:', error.message);
}
productCoverageCache = {
warehouse: WAREHOUSE_NAME,
available,
csv: covered.join(','),
set: new Set(covered),
total: codes.length,
coveredCount: covered.length,
at: now,
};
return productCoverageCache;
}
const mapRow = (row = {}) => {
const realtimeStock = Number(row.realtimeStock || 0);
const stockDays = toNumber(row.stockDays);
const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS;
const warningLevel = normalizeWarningLevel(realtimeStock, stockDays);
return {
skuId: toText(row.skuId),
productName: toText(row.productName),
category: toText(row.category),
// 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空)
imageUrl: toText(row.productImageUrl) || toText(row.imageUrl),
warningLevel,
warningOrder: WARNING_ORDER[warningLevel],
mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED,
// 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量
inQtyToday: toNumber(row.inQtyToday) ?? 0,
outQtyToday: toNumber(row.outQtyToday) ?? 0,
realtimeStock,
location: toText(row.location),
avgShip7d: toNumber(row.avgShip7d),
stockDays,
safetyDays,
suggestStock: toNumber(row.suggestStock),
warehouse: toText(row.warehouse) || WAREHOUSE_NAME,
};
};
const buildProjection = () => `
s.sku_id AS skuId,
s.name AS productName,
s.category AS category,
COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock,
ISNULL(pin.inQty, 0) AS inQtyToday,
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) AS location,
m.sevenAvgSale AS avgShip7d,
m.jsUseDays AS stockDays,
m.safetyDays AS safetyDays,
m.suggestStock AS suggestStock,
s.other_3 AS warehouse,
COALESCE(
NULLIF(LTRIM(RTRIM(s.pic_big)), N''),
NULLIF(LTRIM(RTRIM(s.pic)), N''),
cost.pictureSkucode
) AS imageUrl,
CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus,
CASE
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END AS warningOrder,
m.date AS monitorDate`;
async function getModularStockList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const [monitorDate, coverage] = await Promise.all([
resolveMonitorDate(),
resolveProductCoverage(),
]);
const { conditions, params } = buildFilters(query, monitorDate, coverage);
const baseSql = buildBaseSql(coverage);
const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql;
const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock';
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const orderSql = `${SORTABLE_FIELDS[sortField]} ${sortOrder}`;
const offset = (page - 1) * pageSize;
// 已按 s.sku_id 排序时不再追加 tie-breaker,否则 ORDER BY 出现重复列报错 169
const orderClause = SORTABLE_FIELDS[sortField] === 's.sku_id'
? orderSql
: `${orderSql}, s.sku_id ASC`;
const rows = await cloudDatabase.query(
`SELECT ${buildProjection()}
${whereSql}
ORDER BY ${orderClause}
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
);
const [totalRow] = await cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const [summaryRow] = await buildSummary(whereSql, params);
// 用产品资料系统数据库补齐图片(业务库目标仓图片字段全为空)
const enrichedRows = await attachProductImages(rows);
return {
rows: enrichedRows.map(mapRow),
total: Number(totalRow?.total || 0),
page,
pageSize,
warehouse: WAREHOUSE_NAME,
monitorDate: summaryRow?.monitorDate || monitorDate,
summary: {
skuCount: summaryRow?.skuCount || 0,
outOfStockCount: summaryRow?.outOfStockCount || 0,
lowStockDaysCount: summaryRow?.lowStockDaysCount || 0,
configuredCount: summaryRow?.configuredCount || 0,
unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0),
totalStock: summaryRow?.totalStock || 0,
},
};
}
async function buildSummary(whereSql, params) {
const rows = await cloudDatabase.query(
`SELECT
COUNT_BIG(*) AS skuCount,
SUM(CASE WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 1 ELSE 0 END) AS outOfStockCount,
CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock,
SUM(CASE WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 ELSE 0 END) AS lowStockDaysCount,
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,
MAX(m.date) AS monitorDate
${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const row = rows[0] || {};
return [{
skuCount: Number(row.skuCount || 0),
outOfStockCount: Number(row.outOfStockCount || 0),
totalStock: Number(row.totalStock || 0),
lowStockDaysCount: Number(row.lowStockDaysCount || 0),
configuredCount: Number(row.configuredCount || 0),
monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null,
}];
}
async function getModularStockFilters() {
const [monitorDate, coverage] = await Promise.all([
resolveMonitorDate(),
resolveProductCoverage(),
]);
const params = {
wmsCoId: WAREHOUSE_WMS_CO_ID,
warehouseName: WAREHOUSE_NAME,
monitorDate,
};
if (coverage.available) {
params.productCoveredCsv = coverage.csv || '';
}
const rows = await cloudDatabase.query(
`SELECT DISTINCT s.category
${buildBaseSql(coverage)} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N''
ORDER BY s.category`,
params,
{ requestTimeout: 180000 }
);
return {
categories: rows.map((row) => toText(row.category)).filter(Boolean),
warningLevels: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING, WARNING_LEVEL.NORMAL],
mkhStatuses: [MKH_STATUS.CONFIGURED, MKH_STATUS.UNCONFIGURED],
warehouse: WAREHOUSE_NAME,
};
}
module.exports = {
WAREHOUSE_NAME,
WAREHOUSE_WMS_CO_ID,
WARNING_LEVEL,
DEFAULT_SAFETY_DAYS,
getModularStockList,
getModularStockFilters,
};

View File

@ -0,0 +1,430 @@
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 };

View File

@ -0,0 +1,943 @@
'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,
};

View File

@ -0,0 +1,521 @@
'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,
};

View File

@ -2,16 +2,12 @@ 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',
@ -19,7 +15,6 @@ 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'];
@ -53,15 +48,6 @@ 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;

View File

@ -1,54 +0,0 @@
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));
}
});

View File

@ -92,9 +92,11 @@ 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: [] })).productTest, true);
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true);
});
test('SSO ticket is consumed exactly once and produces a normal web token', async () => {

View File

@ -12,10 +12,6 @@ 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);
@ -24,7 +20,6 @@ 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);
});
@ -33,12 +28,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"},{"key":"settings"}]}' }] : [];
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : [];
} });
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, 11);
assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 2);
const update = calls.find((call) => call.sql.startsWith('UPDATE'));
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'settings' }]);
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]);
});

View File

@ -3,11 +3,14 @@ const assert = require('node:assert/strict');
const Module = require('node:module');
const express = require('express');
test('retired analysis endpoints return 404 while retained selection endpoint works', async () => {
test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => {
const originalLoad = Module._load;
Module._load = function (id, parent, ...rest) {
if (parent?.filename.endsWith('/api/planningUpload.js')) {
if (/[\\/]api[\\/]planningUpload\.js$/.test(parent?.filename || '')) {
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 };
}
@ -21,10 +24,10 @@ test('retired analysis endpoints return 404 while retained selection endpoint wo
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', '/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily']) {
for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans']) {
assert.equal((await fetch(base + path)).status, 404, path);
}
for (const path of ['/selection-fission/1688-products/123']) {
for (const path of ['/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily', '/selection-fission/1688-products/123']) {
const response = await fetch(base + path);
assert.equal(response.status, 200, path);
assert.equal((await response.json()).success, true);

View File

@ -143,6 +143,18 @@ 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');
};

View File

@ -1,16 +0,0 @@
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 })
);

View File

@ -0,0 +1,17 @@
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')
);

View File

@ -0,0 +1,16 @@
import request from './request';
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
const DETAIL_TIMEOUT_MS = 3 * 60 * 1000;
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
export const getMkhBomList = (params = {}) => request.get(
'/api/mkh-bom/list',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用)
export const getMkhBomDetail = (skuId) => request.get(
'/api/mkh-bom/detail',
{ params: { skuId }, timeout: DETAIL_TIMEOUT_MS }
);

View File

@ -0,0 +1,16 @@
import request from './request';
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
const META_TIMEOUT_MS = 60 * 1000;
// 配件库存监控明细(默认仅「简尚-崔桥工厂半成品包装项目仓」)
export const getModularStockList = (params = {}) => request.get(
'/api/modular-stock-monitor/list',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 筛选下拉选项(品类 / 预警等级)
export const getModularStockFilters = () => request.get(
'/api/modular-stock-monitor/filters',
{ timeout: META_TIMEOUT_MS }
);

View File

@ -2,6 +2,21 @@ 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: {

View File

@ -0,0 +1,42 @@
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 })
);

View File

@ -0,0 +1,34 @@
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 }
);

View File

@ -0,0 +1,17 @@
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')
);

View File

@ -2,8 +2,30 @@ 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: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
children: [
{ key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 },
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
],
},
{
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
children: [
@ -30,6 +52,28 @@ 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();
@ -45,6 +89,18 @@ 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);
@ -81,10 +137,15 @@ 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, config?.entries || []),
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
};
};

View File

@ -72,7 +72,26 @@ 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' },
'modular-stock-monitor-accessory': {
path: '/planning/operation/modular-stock-monitor',
activePath: '/planning/operation/modular-stock-monitor',
matchMode: 'prefix',
},
'mkh-bom-data': {
path: '/planning/operation/mkh-bom-data',
activePath: '/planning/operation/mkh-bom-data',
matchMode: 'prefix',
},
settings: { path: '/settings' },
'online-desktop-users': { path: '/settings/online-desktop-users' },
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },

View File

@ -24,7 +24,7 @@ import {
SycmSimpleTable,
SycmSparkline,
} from '../../components/Common';
import { getDashboardMeta, getDashboardSummary } from '../../api/dashboard';
import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis';
import './stylePreview.css';
const overviewMetrics = [
@ -180,7 +180,7 @@ const DevelopmentStylePreview = () => {
useEffect(() => {
let active = true;
getDashboardMeta().then((response) => {
getProductAnalysisMeta().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 };
getDashboardSummary(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
getDevelopmentDashboard(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
const payload = response?.data || {};
setDashboard(payload);
}).catch((error) => {

View File

@ -0,0 +1,492 @@
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;

View File

@ -0,0 +1,224 @@
.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;
}
}

View File

@ -0,0 +1,440 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Input, Space, Spin, Table, Tooltip, Typography, message,
} from 'antd';
import { PrinterOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom';
import './style.css';
const { Title, Text } = Typography;
const number = (value, digits = 0) => (value === null || value === undefined || value === ''
? '0'
: Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}));
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
const EMPTY_FILTERS = { keyword: '' };
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
const PrintPreview = ({ data, onClose }) => {
const now = useMemo(() => new Date().toLocaleString('zh-CN'), []);
const items = data.items || [];
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="mkh-print-mask">
<div className="mkh-print-toolbar">
<Space>
<Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>一键打印</Button>
<Button onClick={onClose}>关闭</Button>
<Text type="secondary">按 Esc 关闭预览</Text>
</Space>
</div>
<div className="mkh-print-sheet">
<div className="mkh-print-head">
<div className="mkh-print-title">MKH-BOM 资料</div>
<div className="mkh-print-sub">模块化商品配件明细 · 打印模板</div>
</div>
<div className="mkh-print-meta">
<div><span>打印时间:</span><b>{now}</b></div>
<div><span>配件种类:</span><b>{items.length}</b></div>
</div>
<table className="mkh-print-table">
<thead>
<tr>
<th style={{ width: '170px' }}>MKH商品编码</th>
<th>配件明细</th>
<th style={{ width: '64px' }}>数量</th>
<th style={{ width: '110px' }}>配件仓位</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={item.index}>
{idx === 0 ? (
<td className="mkh-print-sku" rowSpan={items.length}>{text(data.skuId)}</td>
) : null}
<td>{text(item.skuName)}{item.skuId ? <span className="mkh-print-code">{item.skuId}</span> : null}</td>
<td className="mkh-center">{number(item.qty)}</td>
<td className="mkh-center">{item.location ? text(item.location) : <span className="mkh-muted">未维护</span>}</td>
</tr>
))}
{items.length === 0 ? (
<tr>
<td className="mkh-print-sku">{text(data.skuId)}</td>
<td colSpan={3} className="mkh-muted">该 MKH 商品的配件明细尚未录入「产品资料系统数据库」</td>
</tr>
) : null}
</tbody>
</table>
<div className="mkh-print-foot">
<span>
MKH 商品编码取自业务库;配件明细与数量取自「产品资料系统数据库」(BOM);
配件仓位取库位主数据,未维护时显示「未维护」。
</span>
</div>
</div>
</div>
);
};
const MkhBomData = () => {
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [sorter, setSorter] = useState({ field: 'skuId', order: 'ASC' });
const [expandedKeys, setExpandedKeys] = useState({});
const [detailCache, setDetailCache] = useState({});
const [detailLoading, setDetailLoading] = useState({});
const [printData, setPrintData] = useState(null);
const [printLoading, setPrintLoading] = useState(false);
const tableWrapRef = useRef(null);
const [bodyHeight, setBodyHeight] = useState(420);
useEffect(() => {
const el = tableWrapRef.current;
if (!el) return undefined;
const RESERVE_BELOW = 104;
const measure = () => {
const scroller = el.closest('.planning-subsystem-content');
const elRect = el.getBoundingClientRect();
let available;
if (scroller) {
const scrollerRect = scroller.getBoundingClientRect();
available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop;
} else {
available = window.innerHeight - elRect.top - RESERVE_BELOW;
}
const next = Math.max(240, Math.round(available));
setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
if (el.parentElement) ro.observe(el.parentElement);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, []);
const query = useMemo(() => {
const params = { page, pageSize, sortField: sorter.field, sortOrder: sorter.order };
Object.entries(filters).forEach(([key, value]) => { if (value) params[key] = value; });
return params;
}, [page, pageSize, sorter, filters]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await getMkhBomList(query);
if (!result || result.success === false) {
setError(result?.error || '加载 MKH-BOM 资料失败');
return;
}
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
} catch (requestError) {
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => { load(); }, [load]);
const changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
};
const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); };
const loadDetail = useCallback(async (skuId) => {
if (detailCache[skuId] || detailLoading[skuId]) return;
setDetailLoading((old) => ({ ...old, [skuId]: true }));
try {
const result = await getMkhBomDetail(skuId);
if (!result || result.success === false) {
message.error(result?.error || '加载配件明细失败');
return;
}
setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } }));
} catch (e) {
message.error(e?.response?.data?.error || e?.message || '加载配件明细失败');
} finally {
setDetailLoading((old) => ({ ...old, [skuId]: false }));
}
}, [detailCache, detailLoading]);
const handlePrint = async (skuId) => {
setPrintLoading(true);
try {
const result = await getMkhBomDetail(skuId);
if (!result || result.success === false) {
message.error(result?.error || '加载打印数据失败');
return;
}
setPrintData(result?.data || { skuId, items: [] });
} catch (e) {
message.error(e?.response?.data?.error || e?.message || '加载打印数据失败');
} finally {
setPrintLoading(false);
}
};
const columns = useMemo(() => [
{
title: 'MKH商品编码',
dataIndex: 'skuId',
width: 240,
fixed: 'left',
sorter: true,
render: (value, row) => (
<Space size={6}>
<span className="mkh-sku">{text(value)}</span>
{row.source === 'product_db' ? (
<Tooltip title="该编码仅在「产品资料系统数据库」中维护,业务库 jst_sku 暂无对应成品">
<span className="mkh-tag mkh-tag--src">资料库</span>
</Tooltip>
) : null}
</Space>
),
},
{
title: '商品名称',
dataIndex: 'productName',
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{
title: 'BOM 状态',
dataIndex: 'bomConfigured',
width: 100,
align: 'center',
render: (value, row) => (row.bomConfigured === '已配置'
? <span className="mkh-tag mkh-tag--ok">已配置</span>
: <span className="mkh-tag mkh-tag--muted">未配置</span>),
},
{
title: '配件种类',
dataIndex: 'accessoryCount',
width: 110,
align: 'right',
sorter: true,
render: (value) => number(value),
},
{
title: '配件总数量',
dataIndex: 'accessoryQtySum',
width: 120,
align: 'right',
sorter: true,
render: (value) => number(value),
},
{
title: '操作',
key: 'action',
width: 190,
fixed: 'right',
render: (_value, row) => (
<Space size={6}>
<Button
type="primary"
size="small"
icon={<PrinterOutlined />}
loading={printLoading}
onClick={() => handlePrint(row.skuId)}
>
一键打印
</Button>
<Button
size="small"
onClick={() => {
const next = !expandedKeys[row.skuId];
setExpandedKeys((old) => ({ ...old, [row.skuId]: next }));
if (next) loadDetail(row.skuId);
}}
>
{expandedKeys[row.skuId] ? '收起' : '配件明细'}
</Button>
</Space>
),
},
], [expandedKeys, printLoading, loadDetail]);
const expandedRowRender = (row) => {
const detail = detailCache[row.skuId];
if (detailLoading[row.skuId] && !detail) {
return <div className="mkh-detail-loading"><Spin size="small" /> 加载配件明细…</div>;
}
const items = detail?.items || [];
return (
<div className="mkh-detail">
{items.length === 0 ? (
<div className="mkh-detail-tip">
<Text type="secondary">
该 MKH 商品在「产品资料系统数据库」中暂无配件 BOM 录入。
在产品资料系统里维护好该商品的配件明细后,此处会自动显示配件明细、数量与仓位。
</Text>
</div>
) : null}
<Table
rowKey="index"
size="small"
pagination={false}
dataSource={items}
scroll={{ x: 720 }}
locale={{ emptyText: '暂无配件明细' }}
columns={[
{ title: '序号', dataIndex: 'index', width: 60, align: 'center' },
{
title: '配件明细',
dataIndex: 'skuName',
ellipsis: true,
render: (v, r) => (
<Tooltip title={`${v || ''}${r.skuId ? `(${r.skuId})` : ''}`}>
<span>{text(v)}</span>
{r.skuId ? <span className="mkh-detail-code">{r.skuId}</span> : null}
</Tooltip>
),
},
{ title: '数量', dataIndex: 'qty', width: 90, align: 'right', render: (v) => number(v) },
{
title: '配件仓位',
dataIndex: 'location',
width: 140,
render: (v) => (v ? text(v) : (
<Tooltip title="上游未回传库位数据">
<span className="mkh-muted">未维护</span>
</Tooltip>
)),
},
]}
/>
</div>
);
};
return (
<SycmPage className="mkh-page">
<header className="mkh-header">
<div className="mkh-header__heading">
<Title level={3}>MKH-BOM 资料</Title>
<Text type="secondary">
模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位
</Text>
<Text type="secondary" className="mkh-note">
数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条);
配件明细与数量取自「产品资料系统数据库」(PostgreSQL / furniture3d_prod)中模块化商品维护的配件明细(配件条码 + 数量);
资料库中已维护 BOM 但业务库无对应成品的编码会置顶并标注「资料库」;配件仓位取库位主数据,未维护时显示「未维护」。
</Text>
</div>
</header>
<Card className="mkh-filter-card" bordered={false}>
<Space wrap size={[12, 12]}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="MKH商品编码 / 名称"
value={filters.keyword}
onChange={(e) => changeFilter('keyword')(e.target.value)}
style={{ width: 260 }}
/>
<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}
{!loading && (data.total || 0) > 0 ? (
<Alert
type={data.bomRowCount > 0 ? 'info' : 'warning'}
showIcon
message={data.bomRowCount > 0
? `产品资料系统数据库已接入 ${data.bomRowCount} 个模块化商品的配件 BOM`
: '产品资料系统数据库中尚未录入模块化商品的配件 BOM'}
description={(
<span>
MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku;
配件明细与数量取自「产品资料系统数据库」中模块化商品维护的 BOM。
资料库中已维护 BOM、但业务库无对应成品的编码({data.bomExtraCount || 0} 条)会置顶并标注「资料库」。
在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
</span>
)}
/>
) : null}
<Card
bordered={false}
className="mkh-panel"
title={<span>MKH 模块化成品({data.total || 0} 条)</span>}
>
<Spin spinning={loading} tip="正在加载 MKH-BOM 资料…">
<div ref={tableWrapRef}>
<Table
rowKey="skuId"
size="small"
columns={columns}
dataSource={data.rows || []}
expandable={{
expandedRowKeys: Object.keys(expandedKeys).filter((k) => expandedKeys[k]),
expandedRowRender,
onExpand: (expanded, record) => {
setExpandedKeys((old) => ({ ...old, [record.skuId]: expanded }));
if (expanded) loadDetail(record.skuId);
},
}}
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: 1000, y: bodyHeight }}
/>
</div>
</Spin>
</Card>
{printData ? <PrintPreview data={printData} onClose={() => setPrintData(null)} /> : null}
</SycmPage>
);
};
export default MkhBomData;

View File

@ -0,0 +1,239 @@
.mkh-page {
padding: 16px 20px 24px;
}
.mkh-header {
margin-bottom: 14px;
}
.mkh-header__heading .ant-typography {
margin-bottom: 4px;
}
.mkh-note {
display: block;
max-width: 1180px;
margin-top: 6px;
font-size: 12px;
line-height: 1.7;
}
.mkh-filter-card {
margin-bottom: 12px;
}
.mkh-panel {
margin-top: 4px;
}
.mkh-sku {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: var(--sycm-text-primary);
}
.mkh-muted {
color: var(--sycm-text-tertiary);
}
.mkh-detail-loading {
padding: 12px 8px;
color: var(--sycm-text-secondary);
}
.mkh-detail {
padding: 4px 8px 8px;
}
.mkh-detail-tip {
padding: 4px 0 8px;
font-size: 12px;
}
.mkh-detail-code {
display: block;
margin-top: 2px;
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 11px;
color: var(--sycm-text-tertiary);
}
.mkh-tag {
display: inline-block;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.mkh-tag--ok {
color: #0f6e56;
background: rgba(29, 158, 117, 0.14);
}
.mkh-tag--muted {
color: var(--sycm-text-tertiary);
background: rgba(136, 135, 128, 0.14);
}
.mkh-tag--src {
color: #0958d9;
background: rgba(22, 119, 255, 0.12);
}
/* ============ A4 打印预览 ============ */
.mkh-print-mask {
position: fixed;
inset: 0;
z-index: 2000;
overflow: auto;
background: rgba(0, 0, 0, 0.55);
padding: 24px 0 40px;
}
.mkh-print-toolbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
justify-content: center;
padding: 12px;
margin-bottom: 16px;
}
.mkh-print-sheet {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
padding: 14mm 12mm;
background: #fff;
color: #000;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
box-sizing: border-box;
font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
font-size: 12px;
line-height: 1.5;
}
.mkh-print-head {
text-align: center;
border-bottom: 2px solid #000;
padding-bottom: 10px;
margin-bottom: 12px;
}
.mkh-print-title {
font-size: 22px;
font-weight: 700;
letter-spacing: 2px;
}
.mkh-print-sub {
margin-top: 4px;
font-size: 12px;
color: #444;
}
.mkh-print-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 24px;
margin-bottom: 12px;
font-size: 13px;
}
.mkh-print-meta > div {
display: flex;
}
.mkh-print-meta span {
color: #555;
min-width: 78px;
}
.mkh-print-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.mkh-print-table th,
.mkh-print-table td {
border: 1px solid #000;
padding: 6px 8px;
text-align: left;
word-break: break-all;
}
.mkh-print-table thead th {
background: #f2f2f2;
font-weight: 700;
}
.mkh-print-table .mkh-center {
text-align: center;
}
.mkh-print-table .mkh-muted {
color: #999;
}
.mkh-print-table .mkh-print-sku {
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 11px;
vertical-align: middle;
}
.mkh-print-code {
display: block;
margin-top: 2px;
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 10px;
color: #666;
}
.mkh-print-foot {
margin-top: 16px;
padding-top: 8px;
border-top: 1px dashed #999;
font-size: 11px;
color: #666;
}
/* 打印时只输出 A4 纸张,隐藏其余界面 */
@media print {
body * {
visibility: hidden !important;
}
.mkh-print-sheet,
.mkh-print-sheet * {
visibility: visible !important;
}
.mkh-print-toolbar {
display: none !important;
}
.mkh-print-mask {
position: absolute !important;
inset: 0 !important;
background: #fff !important;
overflow: visible !important;
padding: 0 !important;
}
.mkh-print-sheet {
width: 210mm;
min-height: auto;
margin: 0 !important;
padding: 0 !important;
box-shadow: none !important;
}
@page {
size: A4;
margin: 12mm;
}
}

View File

@ -0,0 +1,428 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select,
Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getModularStockList, getModularStockFilters } from '../../api/modularStockMonitor';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
const WARNING_COLOR = { 缺货: 'red', 预警: 'orange', 正常: 'green' };
// 表头带口径说明,避免把「真实值 0」误读成「没数据」
const TipTitle = ({ title, tip }) => (
<Tooltip title={tip}>
<span className="msm-th-tip">{title}</span>
</Tooltip>
);
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 toOptions = (list = []) => (Array.isArray(list) ? list : [])
.filter(Boolean)
.map((value) => ({ value, label: value }));
const EMPTY_FILTERS = { keyword: '', category: '', warningLevel: '', mkhStatus: '' };
const AccessoryStockPanel = () => {
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [onlyWarning, setOnlyWarning] = useState(false);
const [filterOptions, setFilterOptions] = useState({ categories: [], warningLevels: [] });
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: 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: 'realtimeStock', order: 'DESC' });
const tableWrapRef = useRef(null);
const [bodyHeight, setBodyHeight] = useState(420);
// 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc:
// 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行
useEffect(() => {
const el = tableWrapRef.current;
if (!el) return undefined;
// 分页器 + 卡片内边距 + 横向滚动条余量
const RESERVE_BELOW = 104;
const measure = () => {
const scroller = el.closest('.planning-subsystem-content');
const elRect = el.getBoundingClientRect();
let available;
if (scroller) {
const scrollerRect = scroller.getBoundingClientRect();
// 加回 scrollTop,抵消滚动对 getBoundingClientRect 的影响
available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop;
} else {
available = window.innerHeight - elRect.top - RESERVE_BELOW;
}
const next = Math.max(240, Math.round(available));
setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
if (el.parentElement) ro.observe(el.parentElement);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, []);
useEffect(() => {
let cancelled = false;
getModularStockFilters()
.then((result) => {
const payload = result?.data || {};
if (cancelled) return;
setFilterOptions({
categories: payload.categories || [],
warningLevels: payload.warningLevels || ['缺货', '预警', '正常'],
mkhStatuses: payload.mkhStatuses || ['已配置MKH', '未配置MKH'],
});
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
const query = useMemo(() => {
const params = {
page,
pageSize,
sortField: sorter.field,
sortOrder: sorter.order,
};
Object.entries(filters).forEach(([key, value]) => {
if (value) params[key] = value;
});
if (onlyWarning) params.onlyWarning = '1';
return params;
}, [page, pageSize, sorter, filters, onlyWarning]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await getModularStockList(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 changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
};
const resetFilters = () => {
setFilters(EMPTY_FILTERS);
setOnlyWarning(false);
setPage(1);
};
const columns = useMemo(() => [
{
title: '图片',
dataIndex: 'imageUrl',
width: 56,
fixed: 'left',
exportable: false,
render: (value) => (value
? <Image src={value} width={36} height={36} style={{ objectFit: 'cover', borderRadius: 4 }} placeholder />
: <span className="msm-muted">--</span>),
},
{
title: '预警',
dataIndex: 'warningLevel',
width: 76,
sorter: true,
render: (value) => (value
? <Tag color={WARNING_COLOR[value] || 'default'}>{value}</Tag>
: <span className="msm-muted">--</span>),
},
{
title: <TipTitle
title="状态"
tip="该 SKU 是否已在「产品资料系统数据库」中建立商品档案。已配置MKH = 产品资料库中能查到该 SKU 编码的商品资料;未配置MKH = 产品资料库中查不到该 SKU 档案"
/>,
dataIndex: 'mkhStatus',
width: 110,
sorter: true,
render: (value) => {
const v = value || '未配置MKH';
return v === '已配置MKH'
? <Tag color="green">{v}</Tag>
: <Tag color="default">{v}</Tag>;
},
},
{
title: 'SKU编码',
dataIndex: 'skuId',
width: 176,
fixed: 'left',
sorter: true,
render: (value) => <span className="msm-sku">{text(value)}</span>,
},
{
// 必须显式给 width:antd 在 scroll.x 场景下会把无宽度列压成 0 宽(此前该列不可见的原因)
title: '商品名称',
dataIndex: 'productName',
width: 220,
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{
title: <TipTitle
title="当日入库库存"
tip="监控日(pm_stockMonitor 最新快照日)的采购到货入库数量,取自 jst_purchaseItem.inQty。业务库无出入库流水表,0 表示该 SKU 当日无到货"
/>,
dataIndex: 'inQtyToday',
width: 116,
align: 'right',
render: (value) => (value === null || value === undefined
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="当日出库库存"
tip="监控日的最近一日发货量,取自 pm_stockMonitor.nearSecondSale。业务库无出入库流水表,0 表示该 SKU 当日无发货"
/>,
dataIndex: 'outQtyToday',
width: 116,
align: 'right',
render: (value) => (value === null || value === undefined
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: '实时库存',
dataIndex: 'realtimeStock',
width: 96,
align: 'right',
sorter: true,
render: int,
},
{
title: <TipTitle
title="仓位"
tip="业务库 jst_sku.bin / other_bin 全库 55.7 万行均为空,上游未回传库位数据,因此该列暂无值"
/>,
dataIndex: 'location',
width: 88,
sorter: true,
render: (value) => (value
? <span>{value}</span>
: <Tooltip title="上游未提供库位数据">
<span className="msm-muted">未维护</span>
</Tooltip>),
},
{
title: '近7日日均发货量',
dataIndex: 'avgShip7d',
width: 138,
align: 'right',
sorter: true,
render: (value) => number(value),
},
{
title: '库存可用天数',
dataIndex: 'stockDays',
width: 116,
align: 'right',
sorter: true,
render: (value) => number(value, 1),
},
{
title: '所在仓库',
dataIndex: 'warehouse',
width: 200,
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
], []);
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,
`配件库存监控_${data.monitorDate || '最新'}`,
exportColumns,
data.rows,
'配件库存监控'
);
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
} catch (exportError) {
message.error(exportError?.message || '导出失败');
}
};
const summary = data.summary || {};
return (
<>
<Card className="msm-filter-card" bordered={false}>
<Space wrap size={[12, 12]}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="SKU 编码 / 名称"
value={filters.keyword}
onChange={(e) => changeFilter('keyword')(e.target.value)}
style={{ width: 220 }}
/>
<Select
allowClear
showSearch
placeholder="品类"
options={toOptions(filterOptions.categories)}
value={filters.category || undefined}
onChange={changeFilter('category')}
style={{ width: 200 }}
/>
<Select
allowClear
placeholder="预警等级"
options={toOptions(filterOptions.warningLevels)}
value={filters.warningLevel || undefined}
onChange={changeFilter('warningLevel')}
style={{ width: 140 }}
/>
<Select
allowClear
placeholder="MKH 配置状态"
options={toOptions(filterOptions.mkhStatuses)}
value={filters.mkhStatus || undefined}
onChange={changeFilter('mkhStatus')}
style={{ width: 160 }}
/>
<Space size={6}>
<Switch checked={onlyWarning} onChange={(checked) => { setOnlyWarning(checked); setPage(1); }} size="small" />
<Text type="secondary">仅看缺货 / 预警</Text>
</Space>
<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="msm-summary-row">
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-red"><Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-orange"><Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-green"><Statistic title="实时库存合计" value={summary.totalStock || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="已配置MKH" value={summary.configuredCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} /></Card></Col>
</Row>
<Card
bordered={false}
className="msm-panel"
title={(
<Space size={12} wrap>
<span>配件库存明细({data.total || 0} 条)</span>
{data.monitorDate ? <Text type="secondary" className="msm-panel__date">监控日 {data.monitorDate}</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="正在加载配件库存监控数据…">
<div ref={tableWrapRef}>
<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: 1508, y: bodyHeight }}
/>
</div>
</Spin>
</Card>
</>
);
};
const ModularStockMonitor = () => (
<SycmPage className="msm-page">
<header className="msm-header">
<div className="msm-header__heading">
<Title level={3}>模块化库存监控</Title>
<Text type="secondary">
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数
</Text>
<Text type="secondary" className="msm-note">
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」;
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。
</Text>
</div>
</header>
<Tabs
className="msm-tabs"
activeKey="accessory"
items={[{
key: 'accessory',
label: '配件库存监控',
children: <AccessoryStockPanel />,
}]}
/>
</SycmPage>
);
export default ModularStockMonitor;

View File

@ -0,0 +1,156 @@
.msm-page {
container-name: modular-stock-monitor;
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);
}
.msm-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);
}
.msm-header__heading { min-width: 0; }
.msm-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);
}
.msm-header__heading .ant-typography {
display: block;
margin-top: 6px;
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
}
.msm-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);
}
.msm-tabs .ant-tabs-tab {
margin: 0;
padding: 12px 4px;
font-size: var(--sycm-font-size-title);
}
.msm-tabs .ant-tabs-content-holder,
.msm-tabs .ant-tabs-content,
.msm-tabs .ant-tabs-tabpane { min-height: 100%; }
.msm-tabs .ant-tabs-tabpane {
display: flex;
flex-direction: column;
gap: var(--sycm-page-section-gap);
padding-top: var(--sycm-page-section-gap);
}
.msm-filter-card,
.msm-panel,
.msm-summary-card {
border: 1px solid var(--sycm-border-card) !important;
border-radius: var(--sycm-radius-lg) !important;
box-shadow: var(--sycm-shadow-card) !important;
}
.msm-filter-card { margin-bottom: 0; }
.msm-filter-card .ant-card-body { padding: 14px 16px; }
.msm-filter-card .ant-space { width: 100%; }
.msm-filter-card .ant-space-item { max-width: 100%; }
.msm-filter-card .ant-select,
.msm-filter-card .ant-input-affix-wrapper { max-width: 100%; }
.msm-summary-row { margin-top: -4px; }
.msm-summary-row .ant-col { display: flex; }
.msm-summary-card {
position: relative;
width: 100%;
min-height: 98px;
overflow: hidden;
}
.msm-summary-card::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--sycm-color-primary);
content: '';
}
.msm-summary-card.is-red::before { background: var(--sycm-color-danger); }
.msm-summary-card.is-orange::before { background: var(--sycm-color-orange); }
.msm-summary-card.is-green::before { background: var(--sycm-color-success); }
.msm-summary-card .ant-card-body {
display: flex;
min-height: 98px;
align-items: center;
padding: 16px 18px;
}
.msm-summary-card .ant-statistic { width: 100%; }
.msm-summary-card .ant-statistic-title {
margin-bottom: 8px;
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
}
.msm-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;
}
.msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); }
.msm-panel { margin-bottom: 0; overflow: hidden; }
.msm-panel .ant-card-head {
min-height: 48px;
padding: 0 16px;
border-bottom-color: var(--sycm-border-light);
}
.msm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); }
.msm-panel .ant-card-body { padding: 0 16px 16px; }
.msm-panel .ant-table { font-size: var(--sycm-font-size-caption); }
.msm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); }
.msm-panel .ant-table-placeholder .ant-table-cell { height: 180px; }
.msm-panel__date { font-size: var(--sycm-font-size-caption); }
.msm-muted { color: var(--sycm-text-tertiary); }
/* 表头口径提示:虚线下划线 + help 光标 */
.msm-th-tip {
border-bottom: 1px dashed var(--sycm-border-strong, var(--sycm-text-tertiary));
cursor: help;
}
.msm-note {
display: block;
max-width: 1180px;
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
}
.msm-sku {
font-family: var(--sycm-font-family-number);
font-weight: var(--sycm-font-weight-semibold);
}
@container modular-stock-monitor (max-width: 900px) {
.msm-filter-card .ant-space-item { flex: 1 1 180px; }
}
@media (max-width: 767px) {
.msm-header {
min-height: 72px;
align-items: flex-start;
flex-direction: column;
gap: var(--sycm-space-sm);
padding-block: 12px;
}
.msm-filter-card .ant-select,
.msm-filter-card .ant-input-affix-wrapper { width: 100%; }
.msm-panel .ant-card-body { padding-inline: 10px; }
}

View File

@ -0,0 +1,233 @@
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;

View File

@ -0,0 +1,160 @@
.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; }
}

View File

@ -0,0 +1,990 @@
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;

View File

@ -0,0 +1,776 @@
.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;
}
}

View File

@ -0,0 +1,340 @@
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;

View File

@ -0,0 +1,167 @@
.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; }
}

View File

@ -0,0 +1,610 @@
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;

View File

@ -0,0 +1,214 @@
.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;
}
}

View File

@ -0,0 +1,340 @@
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;

View File

@ -0,0 +1,372 @@
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;

View File

@ -9,7 +9,7 @@ import {
SaveOutlined, ReloadOutlined, ApiOutlined, AimOutlined,
EditOutlined, DeleteOutlined, PlusOutlined, GlobalOutlined,
UploadOutlined, PictureOutlined, FieldTimeOutlined, CloudUploadOutlined, DownloadOutlined,
RobotOutlined, KeyOutlined
RobotOutlined, KeyOutlined, TagsOutlined
} 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,6 +24,8 @@ 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;
@ -31,7 +33,7 @@ const { Option } = Select;
const SETTINGS_TAB_KEYS = new Set([
'site', 'sidebar', 'auth', 'databases', 'redis', 'workOrderSystem', 'apiKeys',
'selectionFissionAi', 'alibaba1688ImageSearch',
'productCodeImport', 'kpiTargets', 'backfill', 'user', 'dept',
'productCodeImport', 'kpiTargets', 'productTags', 'backfill', 'user', 'dept',
'permission', 's3',
]);
@ -1798,6 +1800,21 @@ 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>,

View File

@ -0,0 +1,106 @@
/* 商品标签配置(系统设置 → 商品标签配置)
* 全部使用 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;
}
}

View File

@ -9,9 +9,16 @@ 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 ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
const lazyLoadingElement = <PageLoadingFallback />;
@ -64,10 +71,46 @@ 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/modular-stock-monitor',
element: renderLazyComponent(ModularStockMonitor),
},
{
path: 'planning/operation/mkh-bom-data',
element: renderLazyComponent(MkhBomData),
},
{ 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 /> },

View File

@ -13,15 +13,10 @@ 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', 'product-monitoring', 'ops-b-incentive',
'marketing-management', 'marketing-events', 'product-management', 'product-analysis',
].includes(entry.key)));
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.some((entry) => entry.key === 'settings'));
assert.ok(entries.some((entry) => entry.key === 'product-analysis'));
});

View File

@ -11,6 +11,29 @@ const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access');
const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json');
const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json');
/**
* 解析后端实际监听端口。
* 优先级:CANGKU_BACKEND_PORT 环境变量 > backend/.env 的 PORT > 默认 4001。
* 前端 dev server 的 /api 代理必须指向同一端口,否则请求会被转发到空端口。
*/
function resolveBackendPort() {
const fromEnv = Number(process.env.CANGKU_BACKEND_PORT);
if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) {
return fromEnv;
}
const envPath = path.join(ROOT_DIR, 'backend', '.env');
if (fs.existsSync(envPath)) {
const matched = fs.readFileSync(envPath, 'utf8').match(/^PORT=(\d+)\s*$/mu);
const fromFile = Number(matched?.[1]);
if (Number.isInteger(fromFile) && fromFile > 0 && fromFile < 65536) {
return fromFile;
}
}
return 4001;
}
export function buildSshArgs(config) {
return [
'-N', '-T',
@ -124,12 +147,14 @@ export async function main(mode = process.argv[2] || 'tunnel') {
CANGKU_REMOTE_SUPERVISOR: 'true',
};
if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) {
childEnvironment.VITE_API_PROXY_TARGET = `http://127.0.0.1:${process.env.CANGKU_BACKEND_PORT || 4000}`;
childEnvironment.VITE_API_PROXY_TARGET = `http://localhost:${resolveBackendPort()}`;
}
const child = spawn(npmCommand, args, {
cwd: ROOT_DIR,
stdio: 'inherit',
env: childEnvironment,
// Windows 下 .cmd 不是可执行文件,必须经 shell 启动,否则 spawn 直接抛 EINVAL
shell: process.platform === 'win32',
});
children.add(child);
child.once('exit', (code) => {

View File

@ -0,0 +1,111 @@
-- 商品标签配置:规模标签阈值 / 利润标签阈值 / 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 语句后手工执行。

View File

@ -0,0 +1,7 @@
-- 商品分析:用户个性化阈值表(系统库,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='商品分析用户个性化配置';