diff --git a/README.md b/README.md index c51db3c..e9b1da4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。 -保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。 +保留仪表盘、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。 已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。 @@ -27,7 +27,7 @@ npm --prefix frontend run check 前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。 -启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。 +启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。迁移仅作用于实际配置给此实例的系统数据库。 ## 参与修改 diff --git a/backend/package.json b/backend/package.json index 7287236..a4c40f8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,8 +9,7 @@ "syntax": "node scripts/check-syntax.js", "test": "node --test test/*.test.js", "check": "npm run syntax && npm test", - "alert:check": "node src/services/alertChecker.js", - "product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js" + "alert:check": "node src/services/alertChecker.js" }, "dependencies": { "@aws-sdk/client-s3": "3.975.0", diff --git a/backend/scripts/seed_product_tag_config_from_xlsx.js b/backend/scripts/seed_product_tag_config_from_xlsx.js deleted file mode 100644 index 40e9e80..0000000 --- a/backend/scripts/seed_product_tag_config_from_xlsx.js +++ /dev/null @@ -1,404 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * 从《公司SABC标签配置.xlsx》导入商品标签配置到系统库。 - * - * 用法: - * node scripts/seed_product_tag_config_from_xlsx.js --file=/path/to/公司SABC标签配置.xlsx - * node scripts/seed_product_tag_config_from_xlsx.js --file=... --print-sql # 只打印 SQL,不写库 - * node scripts/seed_product_tag_config_from_xlsx.js --file=... --dry-run # 只解析并打印摘要 - * - * 解析口径(2026-09-11 评审确认): - * - 区块① A3:F150 → 规模标签阈值(148 行,37 品类 × 4 档) - * - 区块② H4:L28 → SABC 映射(25 行,5 规模标签 × 5 利润标签) - * - 阈值统一四舍五入保留 2 位小数(决策 5,2026-09-11 修订);品类为 '-' 或空的行忽略 - * - * 前置:三张表需已存在(DBA 执行 sql/20260911_create_product_tag_config_system_mysql.sql, - * 或由 ensureProductTagConfigSchema() 自动建表)。 - */ - -const path = require('path'); -const fs = require('fs'); -require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); - -const XLSX = require('xlsx'); -const mysql = require('mysql2/promise'); -const database = require('../src/config/database'); - -const SCALE_TAG_ORDER = ['爆款', '热卖款', '动销款', '低动销款']; -const IGNORED_CATEGORIES = ['-', '']; -const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级']; - -// 配置表聚合类目 → DB 类目 的兜底映射(按用户 2026-09-11 推测确认)。 -// xlsx 里「其他沙发类」「办公家具」本身是聚合类目,DB 中没有同名完整类目; -// 其子品类在 DB 中独立存在,且未被别的配置名占用(懒人沙发/文具边柜已单独配置,故排除)。 -// 用「家族规则」而非逐个枚举叶子名,可自动覆盖 DB 中实际存在的全部子类目,避免名字写错产生幽灵行。 -// family: 'sofa' → 所有短名含「沙发」的类目,排除 exclude 列表 -// family: 'office'→ 所有「商业/办公家具 -」前缀的类目,排除 exclude 列表 -const MANUAL_CATEGORY_MAP = { - '其他沙发类': { family: 'sofa', exclude: ['懒人沙发'] }, - '办公家具': { family: 'office', exclude: ['文具边柜'] }, -}; - -// 区块① / 区块② 在 sheet 中的位置(1-based,与 Excel 界面一致) -const BLOCK1 = { startRow: 3, endRow: 150, cols: { category: 0, pMin: 1, pMax: 2, sMin: 3, sMax: 4, scaleTag: 5 } }; -const BLOCK2 = { startRow: 4, endRow: 28, cols: { scaleTag: 7, profitLevel: 8, profitText: 9, sabc: 11 } }; - -// 去掉「住宅家具 - 」这类一级类目前缀,只留叶子品类名 -function stripCategoryPrefix(value) { - return String(value).replace(/^.*?\s+-\s+/, '').trim(); -} - -function parseArgs(argv) { - const args = { file: '', printSql: false, dryRun: false }; - argv.slice(2).forEach((item) => { - if (item.startsWith('--file=')) args.file = item.slice('--file='.length); - else if (item === '--print-sql') args.printSql = true; - else if (item === '--dry-run') args.dryRun = true; - }); - return args; -} - -function text(value) { - if (value === null || value === undefined) return ''; - return String(value).trim(); -} - -// 决策 5(2026-09-11 11:12 修订):所有阈值统一「四舍五入保留 2 位小数」。 -// - 0.5 → 0.5(合法业务阈值,不变;不会变成 1) -// - 溢出值 7916.34311148132 → 7916.34;2063.13142776357 → 2063.13 -// - 整数 12000 → 12000 -function normalizeThreshold(value) { - if (value === null || value === undefined || value === '') return null; - const num = Number(value); - if (!Number.isFinite(num)) return null; - return Math.round(num * 100) / 100; -} - -function sqlText(value) { - if (value === null || value === undefined) return 'NULL'; - return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; -} - -function sqlNumber(value) { - return value === null || value === undefined ? 'NULL' : String(value); -} - -function readWorkbook(filePath) { - if (!fs.existsSync(filePath)) { - throw new Error(`文件不存在:${filePath}`); - } - const workbook = XLSX.readFile(filePath); - const sheetName = workbook.SheetNames[0]; - const sheet = workbook.Sheets[sheetName]; - const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null }); - return { sheetName, rows }; -} - -function parseScaleThresholds(rows) { - const list = []; - const skipped = []; - for (let rowNo = BLOCK1.startRow; rowNo <= BLOCK1.endRow; rowNo += 1) { - const row = rows[rowNo - 1] || []; - const category = text(row[BLOCK1.cols.category]); - if (IGNORED_CATEGORIES.includes(category)) { - if (category) skipped.push({ rowNo, reason: `品类为 "${category}",按决策 6 忽略` }); - continue; - } - const scaleTag = text(row[BLOCK1.cols.scaleTag]); - if (!SCALE_TAG_ORDER.includes(scaleTag)) { - skipped.push({ rowNo, reason: `规模标签 "${scaleTag}" 不在枚举内` }); - continue; - } - const raw = { - performanceMin: row[BLOCK1.cols.pMin], - performanceMax: row[BLOCK1.cols.pMax], - salesMin: row[BLOCK1.cols.sMin], - salesMax: row[BLOCK1.cols.sMax], - }; - const rounded = { - performanceMin: normalizeThreshold(raw.performanceMin), - performanceMax: normalizeThreshold(raw.performanceMax), - salesMin: normalizeThreshold(raw.salesMin), - salesMax: normalizeThreshold(raw.salesMax), - }; - // 只记录「确实被取整」的溢出值,便于核对(决策 5) - Object.entries(raw).forEach(([key, value]) => { - const before = Number(value); - if (!Number.isFinite(before)) return; - if (rounded[key] !== before) { - skipped.push({ rowNo, reason: `字段 ${key} 原值 ${value} 疑似计算溢出,已取整为 ${rounded[key]}(决策 5)`, rounding: true }); - } - }); - list.push({ - // 配置表里是短名(如「电脑桌/书桌」),稍后会展开成 jst_sku.category 的完整值 - displayName: category, - scaleTag, - ...rounded, - priority: SCALE_TAG_ORDER.indexOf(scaleTag) + 1, - sort: SCALE_TAG_ORDER.indexOf(scaleTag) + 1, - }); - } - return { list, skipped }; -} - -// 读取 jst_sku.category 的全部真实取值(企划库与业务库同表,优先用可达的企划库) -async function loadDbCategories() { - const connection = await mysql.createConnection({ - host: process.env.PLANNING_PUBLIC_DB_HOST, - port: Number(process.env.PLANNING_PUBLIC_DB_PORT), - user: process.env.PLANNING_PUBLIC_DB_USER, - password: process.env.PLANNING_PUBLIC_DB_PASSWORD, - database: process.env.PLANNING_PUBLIC_DB_NAME, - connectTimeout: 20000, - }); - const [rows] = await connection.query( - `SELECT TRIM(category) AS category, COUNT(*) AS skuCount - FROM jst_sku - WHERE category IS NOT NULL AND TRIM(category) <> '' - GROUP BY TRIM(category)` - ); - await connection.end(); - return rows.map((row) => ({ - full: row.category, - short: stripCategoryPrefix(row.category), - skuCount: Number(row.skuCount), - })); -} - -/** - * 把「配置短名」展开为「数据库完整品类名」。 - * 一个短名可能对应多个 DB 品类(如「电脑桌/书桌」→ 住宅家具-电脑桌 + 住宅家具-书桌), - * 展开后每个 DB 品类生成一行,displayName 保留原短名用于展示分组。 - */ -function expandCategories(configRows, dbCategories) { - const matched = []; - const ambiguous = []; - const missing = []; - - configRows.forEach((row) => { - const shortName = row.displayName; - - // 1) 手工映射优先:聚合类目 → DB 类目(家族规则,自动展开子类目) - if (MANUAL_CATEGORY_MAP[shortName]) { - const rule = MANUAL_CATEGORY_MAP[shortName]; - let resolved = []; - if (Array.isArray(rule)) { - // 兼容:显式 DB 短名列表 - rule.forEach((targetShort) => { - const hits = dbCategories.filter((item) => item.short === targetShort); - if (hits.length) hits.forEach((hit) => resolved.push(hit)); - else console.warn(` ⚠️ 手工映射目标「${targetShort}」在 jst_sku 中找不到对应类目,已跳过(请核对 MANUAL_CATEGORY_MAP)`); - }); - } else if (rule && rule.family === 'sofa') { - resolved = dbCategories.filter((item) => item.short.includes('沙发') && !rule.exclude.includes(item.short)); - } else if (rule && rule.family === 'office') { - resolved = dbCategories.filter((item) => /^商业\/办公家具\s*-\s*/.test(item.full) && !rule.exclude.includes(item.short)); - } - if (!resolved.length) { - missing.push(row); - } else { - resolved.forEach((hit) => matched.push({ ...row, category: hit.full })); - } - return; - } - - // 2) 剥离前缀后同名 - let hits = dbCategories.filter((item) => item.short === shortName); - // 3) 包含匹配兜底 - if (!hits.length) { - hits = dbCategories.filter((item) => ( - item.short.includes(shortName) || shortName.includes(item.short) - )); - } - - if (!hits.length) { - missing.push(row); - } else { - hits.forEach((hit) => matched.push({ ...row, category: hit.full })); - if (hits.length > 1) ambiguous.push({ shortName, targets: hits }); - } - }); - - return { matched, ambiguous, missing }; -} - -function parseSabcMappings(rows) { - const list = []; - // H 列(规模标签)是纵向合并单元格(H4:H8、H9:H13 …), - // XLSX.utils.sheet_to_json 只在合并区左上角单元格保留值,其余为 null, - // 因此需要向下填充,否则只能解析出 5 条而不是 25 条。 - let carriedScaleTag = ''; - for (let rowNo = BLOCK2.startRow; rowNo <= BLOCK2.endRow; rowNo += 1) { - const row = rows[rowNo - 1] || []; - const ownScaleTag = text(row[BLOCK2.cols.scaleTag]); - if (ownScaleTag) carriedScaleTag = ownScaleTag; - - const scaleTag = carriedScaleTag; - const profitLevel = text(row[BLOCK2.cols.profitLevel]); - const sabcTag = text(row[BLOCK2.cols.sabc]); - if (!scaleTag || !profitLevel || !sabcTag) continue; - list.push({ scaleTag, profitLevel, sabcTag, sort: list.length + 1 }); - } - return list; -} - -function buildScaleInsertSql(list) { - if (!list.length) return '-- (无规模阈值数据)'; - const values = list.map((row) => ` (${sqlText(row.category)}, ${sqlText(row.displayName)}, ${sqlText(row.scaleTag)}, ` - + `${sqlNumber(row.performanceMin)}, ${sqlNumber(row.performanceMax)}, ` - + `${sqlNumber(row.salesMin)}, ${sqlNumber(row.salesMax)}, ${row.priority}, ${row.sort}, 1)`); - return `INSERT INTO product_scale_thresholds\n` - + ' (category, display_name, scale_tag, performance_min, performance_max, sales_min, sales_max, priority, sort, enabled)\nVALUES\n' - + `${values.join(',\n')}\nON DUPLICATE KEY UPDATE\n` - + ' display_name = VALUES(display_name),\n' - + ' performance_min = VALUES(performance_min), performance_max = VALUES(performance_max),\n' - + ' sales_min = VALUES(sales_min), sales_max = VALUES(sales_max),\n' - + ' priority = VALUES(priority), sort = VALUES(sort), enabled = VALUES(enabled);'; -} - -const PROFIT_LEVEL_RATE = { - 一级: 0.18, - 二级: 0.16, - 三级: 0.12, - 四级: 0.08, - 五级: 0.08, -}; - -async function writeScaleThresholds(list) { - await database.local.query('DELETE FROM product_scale_thresholds'); - if (!list.length) return 0; - const values = []; - const placeholders = list.map((row) => { - values.push( - row.category, row.displayName, row.scaleTag, - row.performanceMin, row.performanceMax, row.salesMin, row.salesMax, - row.priority, row.sort, 1 - ); - return '(?,?,?,?,?,?,?,?,?,?)'; - }); - await database.local.query( - `INSERT INTO product_scale_thresholds - (category, display_name, scale_tag, performance_min, performance_max, - sales_min, sales_max, priority, sort, enabled) - VALUES ${placeholders.join(',')}`, - values - ); - return list.length; -} - -async function writeProfitLevels() { - await database.local.query('DELETE FROM product_profit_levels'); - const values = []; - const placeholders = PROFIT_LEVELS.map((levelCode, index) => { - const rate = PROFIT_LEVEL_RATE[levelCode]; - values.push(levelCode, rate, `营业利润率≤${rate.toFixed(2)}`, index + 1, 1); - return '(?,?,?,?,?)'; - }); - await database.local.query( - `INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled) - VALUES ${placeholders.join(',')}`, - values - ); - return PROFIT_LEVELS.length; -} - -async function writeSabcMappings(list) { - await database.local.query('DELETE FROM product_sabc_mappings'); - if (!list.length) return 0; - const values = []; - const placeholders = list.map((row) => { - values.push(row.scaleTag, row.profitLevel, row.sabcTag, row.sort, 1); - return '(?,?,?,?,?)'; - }); - await database.local.query( - `INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled) - VALUES ${placeholders.join(',')}`, - values - ); - return list.length; -} - -async function main() { - const args = parseArgs(process.argv); - if (!args.file) { - console.error('缺少 --file= 参数'); - 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; - }); diff --git a/backend/scripts/sync_jian_sun_product_analysis_tables.js b/backend/scripts/sync_jian_sun_product_analysis_tables.js deleted file mode 100644 index 38a6bbd..0000000 --- a/backend/scripts/sync_jian_sun_product_analysis_tables.js +++ /dev/null @@ -1,218 +0,0 @@ -require('dotenv').config(); - -const sql = require('mssql'); - -const batchSize = Math.max(100, Number.parseInt(process.env.PRODUCT_ANALYSIS_SYNC_BATCH_SIZE || '5000', 10)); -const targetSchema = /^[A-Za-z_][A-Za-z0-9_]*$/.test(process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA || '') - ? process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA - : 'app'; - -const sourceConfig = { - server: process.env.JIAN_SUN_DB_HOST, - port: Number(process.env.JIAN_SUN_DB_PORT || 1433), - database: process.env.JIAN_SUN_DB_NAME || 'JianSun', - user: process.env.JIAN_SUN_DB_USER, - password: process.env.JIAN_SUN_DB_PASSWORD, - options: { encrypt: false, trustServerCertificate: true }, - connectionTimeout: 120000, - requestTimeout: 120000, - pool: { max: 4, min: 0, idleTimeoutMillis: 120000 }, -}; - -const targetConfig = { - server: process.env.CLOUD_DB_PUBLIC_HOST, - port: Number(process.env.CLOUD_DB_PUBLIC_PORT || 1433), - database: process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica', - user: process.env.CLOUD_DB_PUBLIC_USER, - password: process.env.CLOUD_DB_PUBLIC_PASSWORD, - options: { - encrypt: String(process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true', - trustServerCertificate: true, - }, - connectionTimeout: 120000, - requestTimeout: 120000, - pool: { max: 4, min: 0, idleTimeoutMillis: 120000 }, -}; - -const tables = [ - { - name: 'pdd_DataActivityRecords', - createColumns: ` - [id] int NOT NULL, - [shopName] varchar(40) NULL, - [createTime] varchar(20) NULL, - [business_type_str] varchar(100) NULL, - [activity_name] varchar(100) NULL, - [activity_id] int NULL, - [goods_name] varchar(100) NULL, - [goods_id] bigint NULL, - [hd_thumb_url] varchar(100) NULL, - [enroll_time ] bigint NULL, - [min_activity_sku_price] decimal(38, 2) NULL, - [max_activity_sku_price] decimal(38, 2) NULL, - [activity_goods_start_time] bigint NULL, - [activity_goods_end_time] bigint NULL, - [status] int NULL`, - columns: [ - ['id', sql.Int, false], - ['shopName', sql.VarChar(40), true], - ['createTime', sql.VarChar(20), true], - ['business_type_str', sql.VarChar(100), true], - ['activity_name', sql.VarChar(100), true], - ['activity_id', sql.Int, true], - ['goods_name', sql.VarChar(100), true], - ['goods_id', sql.BigInt, true], - ['hd_thumb_url', sql.VarChar(100), true], - ['enroll_time ', sql.BigInt, true], - ['min_activity_sku_price', sql.Decimal(38, 2), true], - ['max_activity_sku_price', sql.Decimal(38, 2), true], - ['activity_goods_start_time', sql.BigInt, true], - ['activity_goods_end_time', sql.BigInt, true], - ['status', sql.Int, true], - ], - indexColumn: 'goods_id', - }, - { - name: 'pdd_GoodsQualities', - createColumns: ` - [id] int NOT NULL, - [avgDescRevScr1m] float NULL, - [qltyRfndOrdrCntRto1m] float NULL, - [ptHelpPzRate30d] float NULL, - [midRto30d] float NULL, - [goodsId] bigint NULL, - [displayMallScoreRk] int NULL, - [rankAvgDescRevScr1mStage] int NULL, - [rankAvgDescRevScr1mStplStage] int NULL, - [rankQltyRfndOrdrCntRto1mStage] int NULL, - [rankQltyRfndOrdrCntRto1mStplStage] int NULL, - [rankPtHelpPzRate30dStage] int NULL, - [rankPtHelpPzRate30dStplStage] int NULL, - [rankComscoreStage ] int NULL, - [rankComscoreStplStage] int NULL, - [goodsScoreRkStage] int NULL, - [goodsScoreRkStplStage] int NULL, - [createTime] varchar(20) NULL, - [shopName] varchar(40) NULL`, - columns: [ - ['id', sql.Int, false], - ['avgDescRevScr1m', sql.Float, true], - ['qltyRfndOrdrCntRto1m', sql.Float, true], - ['ptHelpPzRate30d', sql.Float, true], - ['midRto30d', sql.Float, true], - ['goodsId', sql.BigInt, true], - ['displayMallScoreRk', sql.Int, true], - ['rankAvgDescRevScr1mStage', sql.Int, true], - ['rankAvgDescRevScr1mStplStage', sql.Int, true], - ['rankQltyRfndOrdrCntRto1mStage', sql.Int, true], - ['rankQltyRfndOrdrCntRto1mStplStage', sql.Int, true], - ['rankPtHelpPzRate30dStage', sql.Int, true], - ['rankPtHelpPzRate30dStplStage', sql.Int, true], - ['rankComscoreStage ', sql.Int, true], - ['rankComscoreStplStage', sql.Int, true], - ['goodsScoreRkStage', sql.Int, true], - ['goodsScoreRkStplStage', sql.Int, true], - ['createTime', sql.VarChar(20), true], - ['shopName', sql.VarChar(40), true], - ], - indexColumn: 'goodsId', - }, -]; - -function qualified(schema, tableName) { - return `[${schema}].[${tableName}]`; -} - -function assertConfig(config, label) { - const missing = ['server', 'database', 'user', 'password'].filter((key) => !config[key]); - if (missing.length) throw new Error(`${label} missing configuration: ${missing.join(', ')}`); -} - -async function ensureTargetTable(pool, definition) { - const tableName = qualified(targetSchema, definition.name); - const pkName = `PK_${definition.name}`; - const lookupIndex = `IX_${definition.name}_${definition.indexColumn}_createTime`; - await pool.request().batch(` - IF OBJECT_ID(N'${tableName}', N'U') IS NULL - BEGIN - CREATE TABLE ${tableName} ( - ${definition.createColumns}, - CONSTRAINT [${pkName}] PRIMARY KEY CLUSTERED ([id]) - ); - END; - IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'${tableName}') AND name = N'${lookupIndex}') - BEGIN - CREATE INDEX [${lookupIndex}] ON ${tableName} ([${definition.indexColumn}], [createTime]); - END; - `); -} - -function createBulkTable(definition) { - const table = new sql.Table(definition.name); - table.schema = targetSchema; - table.create = false; - definition.columns.forEach(([name, type, nullable]) => table.columns.add(name, type, { nullable })); - return table; -} - -async function syncTable(sourcePool, targetPool, definition) { - await ensureTargetTable(targetPool, definition); - const sourceName = qualified('dbo', definition.name); - const targetName = qualified(targetSchema, definition.name); - const columnSql = definition.columns.map(([name]) => `[${name}]`).join(', '); - const targetState = await targetPool.request().query(`SELECT COALESCE(MAX([id]), 0) AS maxId FROM ${targetName}`); - let lastId = Number(targetState.recordset[0].maxId || 0); - let copied = 0; - - while (true) { - const result = await sourcePool.request() - .input('lastId', sql.Int, lastId) - .query(`SELECT TOP (${batchSize}) ${columnSql} FROM ${sourceName} WHERE [id] > @lastId ORDER BY [id]`); - const rows = result.recordset || []; - if (!rows.length) break; - - const bulkTable = createBulkTable(definition); - rows.forEach((row) => bulkTable.rows.add(...definition.columns.map(([name]) => row[name]))); - await targetPool.request().bulk(bulkTable); - lastId = Number(rows[rows.length - 1].id); - copied += rows.length; - console.log(`[product-analysis-sync] ${definition.name}: copied ${copied}, target max id ${lastId}`); - } - - const [sourceStats, targetStats] = await Promise.all([ - sourcePool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${sourceName}`), - targetPool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${targetName}`), - ]); - return { copied, source: sourceStats.recordset[0], target: targetStats.recordset[0] }; -} - -async function syncProductAnalysisReplica() { - assertConfig(sourceConfig, 'JianSun source'); - assertConfig(targetConfig, 'JSReportReplica target'); - const [sourcePool, targetPool] = await Promise.all([ - new sql.ConnectionPool(sourceConfig).connect(), - new sql.ConnectionPool(targetConfig).connect(), - ]); - - try { - const summary = {}; - for (const definition of tables) { - summary[definition.name] = await syncTable(sourcePool, targetPool, definition); - } - console.log(JSON.stringify(summary, null, 2)); - return summary; - } finally { - await Promise.all([sourcePool.close(), targetPool.close()]); - } -} - -if (require.main === module) { - syncProductAnalysisReplica().catch((error) => { - console.error('[product-analysis-sync] failed:', error.stack || error.message); - process.exitCode = 1; - }); -} - -module.exports = { - syncProductAnalysisReplica, -}; diff --git a/backend/sql/20260911_create_product_tag_rules_and_dept_owners.sql b/backend/sql/20260911_create_product_tag_rules_and_dept_owners.sql deleted file mode 100644 index ec24a51..0000000 --- a/backend/sql/20260911_create_product_tag_rules_and_dept_owners.sql +++ /dev/null @@ -1,55 +0,0 @@ --- 商品监控配套:部门商品标签规则 + 部门SKU负责人配置 --- 目标库:系统库 shujuku3_system(MySQL / MariaDB) --- 创建日期:2026-09-11 --- --- 说明: --- 1) product_tag_rules 部门标签 G-S/G-A/G-B/G-C 的阈值规则(tag_scope='department') --- 与公司 SABC(tag_scope='company')共用一张表,靠 tag_scope 区分。 --- ⚠️ 公司 SABC 目前由「商品标签配置」页的三张表承载,本表仅启用 department 分支。 --- 2) department_sku_owners 部门 × SKU/SPU → 负责人 的手工维护映射 --- --- 本文件与 services/localSchema.js 的 ensureProductMonitoringSchema() 等价, --- 服务启动时会自动 CREATE TABLE IF NOT EXISTS,本文件供 DBA 手工建表使用。 - -CREATE TABLE IF NOT EXISTS product_tag_rules ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department', - tag_level VARCHAR(16) NOT NULL COMMENT '部门标签:G-S/G-A/G-B/G-C(company 分支预留 S/A/B/C/D)', - metric_key VARCHAR(64) NOT NULL COMMENT '阈值指标:sales_qty_7d(近7天销售件数)/ sales_amount_7d(近7天销售额)', - threshold_min DECIMAL(18,4) NULL COMMENT '区间起点(含)', - threshold_max DECIMAL(18,4) NULL COMMENT '区间终点(不含)', - priority INT NOT NULL DEFAULT 0 COMMENT '同 scope 内升序命中即停', - enabled TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key), - KEY idx_scope_enabled (tag_scope, enabled), - KEY idx_metric (metric_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'; - --- 初始数据:部门标签四档,默认口径为近 7 天销售件数(sales_qty_7d) --- ⚠️ 阈值为占位默认值,需在「系统设置 → 部门商品标签配置」中按业务口径调整后生效。 -INSERT INTO product_tag_rules (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) -VALUES - ('department', 'G-S', 'sales_qty_7d', 500, NULL, 1, 1), - ('department', 'G-A', 'sales_qty_7d', 200, 500, 2, 1), - ('department', 'G-B', 'sales_qty_7d', 50, 200, 3, 1), - ('department', 'G-C', 'sales_qty_7d', 0, 50, 4, 1) -ON DUPLICATE KEY UPDATE tag_scope = tag_scope; - - -CREATE TABLE IF NOT EXISTS department_sku_owners ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - department_id BIGINT UNSIGNED NOT NULL COMMENT '关联 departments.id', - owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser', - user_id BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id', - spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人;填值则为该 SPU 的负责人', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id), - KEY idx_dept (department_id), - KEY idx_spu (spu), - KEY idx_user (user_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'; diff --git a/backend/src/api/admin.js b/backend/src/api/admin.js index 99e9adb..a9c56c2 100644 --- a/backend/src/api/admin.js +++ b/backend/src/api/admin.js @@ -98,10 +98,6 @@ const { testSelectionFissionAiConfig, } = require('../services/selectionFissionAiSettings'); const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService'); -const { - getProductTagConfig, - saveProductTagConfig, -} = require('../services/productTagConfigService'); const backfillJobs = new Map(); const productCodeImportUpload = multer({ @@ -1215,40 +1211,6 @@ router.post('/selection-fission-ai-settings/image-generation/test', async (_req, -// ==================== 商品标签配置(规模标签 / 利润标签 / SABC 映射) ==================== - -router.get('/product-tag-config', async (_req, res) => { - try { - res.json({ success: true, data: await getProductTagConfig() }); - } catch (error) { - console.error('获取商品标签配置失败:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '获取商品标签配置失败', - }); - } -}); - -router.put('/product-tag-config', async (req, res) => { - try { - const result = await saveProductTagConfig(req.body || {}); - const config = await getProductTagConfig(); - res.json({ - success: true, - message: '商品标签配置已保存到系统数据库', - data: { ...config, saved: result }, - }); - } catch (error) { - console.error('保存商品标签配置失败:', error); - // 校验类错误回 400,方便前端直接展示原因 - const status = /行|重复|不能为空|取值非法|必须小于/.test(error.message || '') ? 400 : (error.statusCode || error.status || 500); - res.status(status).json({ - success: false, - error: error.message || '保存商品标签配置失败', - }); - } -}); - // ==================== 1688 专用以图搜图 ==================== diff --git a/backend/src/api/dashboard.js b/backend/src/api/dashboard.js new file mode 100644 index 0000000..1b4b215 --- /dev/null +++ b/backend/src/api/dashboard.js @@ -0,0 +1,364 @@ +'use strict'; + +const express = require('express'); +const jianSunDb = require('../config/jianSunDatabase'); + +const router = express.Router(); +const DASHBOARD_PERIOD_DAYS = Object.freeze({ day: 1, week: 7, month: 30 }); +const DASHBOARD_CACHE_TTL_MS = 60 * 1000; +const DASHBOARD_QUERY_TIMEOUT_MS = Math.max(Number(process.env.DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000); +const dashboardCache = new Map(); +const dashboardInFlight = new Map(); + +function toNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeDate(value) { + const text = String(value || '').trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; +} + +function toDateString(value) { + if (!value) return ''; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10); +} + +function addDays(value, offset) { + const date = new Date(`${value}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + offset); + return date.toISOString().slice(0, 10); +} + +function inclusiveDayCount(startDate, endDate) { + const start = new Date(`${startDate}T00:00:00Z`); + const end = new Date(`${endDate}T00:00:00Z`); + return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; +} + +function percentChange(current, previous) { + if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0) return null; + return ((current - previous) * 100) / Math.abs(previous); +} + +function sumRows(rows) { + return rows.reduce((result, row) => { + result.payAmount += toNumber(row.payAmount); + result.buyerCount += toNumber(row.buyerCount); + result.visitorCount += toNumber(row.visitorCount); + result.goodsPv += toNumber(row.goodsPv); + result.payGoodsQty += toNumber(row.payGoodsQty); + result.payOrderCount += toNumber(row.payOrderCount); + result.favoriteCount += toNumber(row.favoriteCount); + result.impressionCount += toNumber(row.impressionCount); + return result; + }, { + payAmount: 0, + buyerCount: 0, + visitorCount: 0, + goodsPv: 0, + payGoodsQty: 0, + payOrderCount: 0, + favoriteCount: 0, + impressionCount: 0, + }); +} + +async function runDashboardQuery(name, sqlText, params = {}) { + try { + return { + name, + rows: await jianSunDb.query(sqlText, params, { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }), + error: null, + }; + } catch (error) { + console.warn(`[dashboard] ${name} query failed:`, error.message); + return { name, rows: [], error }; + } +} + +function buildDashboardMetrics(current, previous) { + const conversionRate = current.visitorCount > 0 + ? (current.buyerCount * 100) / current.visitorCount + : null; + const previousConversionRate = previous.visitorCount > 0 + ? (previous.buyerCount * 100) / previous.visitorCount + : null; + return [ + { key: 'payAmount', label: '支付金额', value: current.payAmount, previous: previous.payAmount, change: percentChange(current.payAmount, previous.payAmount), format: 'money' }, + { key: 'payOrderCount', label: '支付订单数', value: Math.round(current.payOrderCount), previous: Math.round(previous.payOrderCount), change: percentChange(current.payOrderCount, previous.payOrderCount), format: 'integer' }, + { key: 'visitorCount', label: '访客数', value: Math.round(current.visitorCount), previous: Math.round(previous.visitorCount), change: percentChange(current.visitorCount, previous.visitorCount), format: 'integer' }, + { key: 'buyerCount', label: '支付买家数', value: Math.round(current.buyerCount), previous: Math.round(previous.buyerCount), change: percentChange(current.buyerCount, previous.buyerCount), format: 'integer' }, + { key: 'conversionRate', label: '支付转化率', value: conversionRate, previous: previousConversionRate, change: percentChange(conversionRate, previousConversionRate), format: 'percent' }, + { key: 'payGoodsQty', label: '支付件数', value: Math.round(current.payGoodsQty), previous: Math.round(previous.payGoodsQty), change: percentChange(current.payGoodsQty, previous.payGoodsQty), format: 'integer' }, + { key: 'favoriteCount', label: '收藏人数', value: Math.round(current.favoriteCount), previous: Math.round(previous.favoriteCount), change: percentChange(current.favoriteCount, previous.favoriteCount), format: 'integer' }, + { key: 'goodsPv', label: '商品浏览量', value: Math.round(current.goodsPv), previous: Math.round(previous.goodsPv), change: percentChange(current.goodsPv, previous.goodsPv), format: 'integer' }, + ]; +} + +function buildEmptyDashboardMetrics() { + return [ + { key: 'payAmount', label: '支付金额', value: null, previous: null, change: null, format: 'money' }, + { key: 'payOrderCount', label: '支付订单数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'visitorCount', label: '访客数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'buyerCount', label: '支付买家数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'conversionRate', label: '支付转化率', value: null, previous: null, change: null, format: 'percent' }, + { key: 'payGoodsQty', label: '支付件数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'favoriteCount', label: '收藏人数', value: null, previous: null, change: null, format: 'integer' }, + { key: 'goodsPv', label: '商品浏览量', value: null, previous: null, change: null, format: 'integer' }, + ]; +} + +router.get('/meta', async (_req, res) => { + try { + const rows = await jianSunDb.query( + `SELECT DISTINCT shopName + FROM dbo.pdd_DataGoods + WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' + ORDER BY shopName`, + {}, + { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS } + ); + res.json({ success: true, data: { platform: 'pdd', shops: rows.map((row) => row.shopName) } }); + } catch (error) { + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + +router.get('/summary', async (req, res) => { + let settleDashboard = null; + let activeCacheKey = ''; + try { + const requestedStartDate = normalizeDate(req.query.startDate); + const requestedEndDate = normalizeDate(req.query.endDate); + const hasCustomDate = Boolean(requestedStartDate || requestedEndDate); + if (hasCustomDate && (!requestedStartDate || !requestedEndDate)) { + return res.status(400).json({ success: false, error: '自定义日期需要同时选择开始日期和结束日期' }); + } + const customDays = hasCustomDate ? inclusiveDayCount(requestedStartDate, requestedEndDate) : 0; + if (hasCustomDate && customDays < 1) { + return res.status(400).json({ success: false, error: '开始日期不能晚于结束日期' }); + } + if (hasCustomDate && customDays > 90) { + return res.status(400).json({ success: false, error: '自定义日期范围不能超过90天' }); + } + const requestedPeriod = hasCustomDate + ? 'custom' + : DASHBOARD_PERIOD_DAYS[req.query.period] ? req.query.period : 'week'; + const shopName = String(req.query.shopName || '').trim(); + const cacheKey = JSON.stringify({ + period: requestedPeriod, + startDate: requestedStartDate, + endDate: requestedEndDate, + shopName, + }); + activeCacheKey = cacheKey; + const cached = dashboardCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return res.json({ success: true, data: cached.data }); + } + const pending = dashboardInFlight.get(cacheKey); + if (pending) { + const result = await pending; + if (result.error) throw result.error; + return res.json({ success: true, data: result.data }); + } + + dashboardInFlight.set(cacheKey, new Promise((resolve) => { + settleDashboard = resolve; + })); + + const period = requestedPeriod; + const days = hasCustomDate ? customDays : DASHBOARD_PERIOD_DAYS[period]; + const [salesResult, serviceResult, starResult] = await Promise.all([ + runDashboardQuery( + 'sales', + `WITH source_bounds AS ( + SELECT TOP (1) LEFT(createTime, 10) AS maxDate + FROM dbo.pdd_DataGoods + WHERE createTime >= '2000-01-01' + AND createTime < '2100-01-01' + AND (@shopName = '' OR shopName = @shopName) + ORDER BY createTime DESC + ), + daily_coverage AS ( + SELECT LEFT(g.createTime, 10) AS statDate, COUNT(DISTINCT g.shopName) AS shopCount + FROM dbo.pdd_DataGoods g + CROSS JOIN source_bounds b + WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, -45, TRY_CONVERT(date, b.maxDate)), 23) + AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, b.maxDate)), 23) + AND g.shopName IS NOT NULL + AND LTRIM(RTRIM(g.shopName)) <> '' + AND (@shopName = '' OR g.shopName = @shopName) + GROUP BY LEFT(g.createTime, 10) + ), + coverage_target AS ( + SELECT MAX(shopCount) AS maxShopCount FROM daily_coverage + ), + available_end AS ( + SELECT MAX(c.statDate) AS latestDate + FROM daily_coverage c + CROSS JOIN coverage_target t + WHERE c.shopCount >= t.maxShopCount * 0.8 + ), + selected_end AS ( + SELECT CASE + WHEN @requestedEndDate <> '' AND @requestedEndDate < latestDate THEN @requestedEndDate + ELSE latestDate + END AS latestDate + FROM available_end + ) + SELECT + LEFT(g.createTime, 10) AS statDate, + e.latestDate AS latestDate, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrAmt), 0)) AS payAmount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrUsrCnt), 0)) AS buyerCount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsUv), 0)) AS visitorCount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsPv), 0)) AS goodsPv, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrGoodsQty), 0)) AS payGoodsQty, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrCnt), 0)) AS payOrderCount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsFavCnt), 0)) AS favoriteCount, + SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.imprUsrCnt), 0)) AS impressionCount + FROM dbo.pdd_DataGoods g + CROSS JOIN selected_end e + WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, 1 - @historyDays, TRY_CONVERT(date, e.latestDate)), 23) + AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, e.latestDate)), 23) + AND (@shopName = '' OR g.shopName = @shopName) + GROUP BY LEFT(g.createTime, 10), e.latestDate + ORDER BY LEFT(g.createTime, 10)`, + { historyDays: days * 2, requestedEndDate: requestedEndDate || '', shopName } + ), + runDashboardQuery( + 'service-score', + `WITH ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber + FROM dbo.pdd_MallServiceScore + WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' + AND createTime LIKE '[12][0-9][0-9][0-9]-%' + AND (@shopName = '' OR shopName = @shopName) + ) + SELECT + MAX(LEFT(createTime, 10)) AS createTime, + COUNT(*) AS shopCount, + AVG(TRY_CONVERT(decimal(18, 6), cstmrServScore)) AS cstmrServScore, + AVG(TRY_CONVERT(decimal(18, 6), spLmScore)) AS spLmScore, + AVG(TRY_CONVERT(decimal(18, 6), fhLmScore)) AS fhLmScore, + AVG(TRY_CONVERT(decimal(18, 6), wlLmScore)) AS wlLmScore + FROM ranked + WHERE rowNumber = 1`, + { shopName } + ), + runDashboardQuery( + 'shop-stars', + `WITH ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber + FROM dbo.pdd_ShopStars + WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' + AND createTime LIKE '[12][0-9][0-9][0-9]-%' + AND (@shopName = '' OR shopName = @shopName) + ) + SELECT + MAX(LEFT(createTime, 10)) AS createTime, + COUNT(*) AS shopCount, + AVG(TRY_CONVERT(decimal(18, 6), mallStarTomms)) AS mallStarTomms, + AVG(TRY_CONVERT(decimal(18, 6), gmvRk)) AS gmvRk, + AVG(TRY_CONVERT(decimal(18, 6), scoreRk)) AS scoreRk + FROM ranked + WHERE rowNumber = 1`, + { shopName } + ), + ]); + const salesRows = salesResult.rows; + const serviceRows = serviceResult.rows; + const starRows = starResult.rows; + const warnings = [salesResult, serviceResult, starResult] + .filter((result) => result.error) + .map((result) => result.name); + const normalizedSales = salesRows + .map((row) => ({ + date: String(row.statDate || ''), + payAmount: toNumber(row.payAmount), + buyerCount: toNumber(row.buyerCount), + visitorCount: toNumber(row.visitorCount), + goodsPv: toNumber(row.goodsPv), + payGoodsQty: toNumber(row.payGoodsQty), + payOrderCount: toNumber(row.payOrderCount), + favoriteCount: toNumber(row.favoriteCount), + impressionCount: toNumber(row.impressionCount), + })) + .filter((row) => normalizeDate(row.date)); + const latestDate = String(salesRows[0]?.latestDate || normalizedSales.at(-1)?.date || ''); + const currentStart = hasCustomDate ? requestedStartDate : latestDate ? addDays(latestDate, 1 - days) : ''; + const previousEnd = currentStart ? addDays(currentStart, -1) : ''; + const previousStart = previousEnd ? addDays(previousEnd, 1 - days) : ''; + const currentRows = normalizedSales.filter((row) => row.date >= currentStart && row.date <= latestDate); + const previousRows = normalizedSales.filter((row) => row.date >= previousStart && row.date <= previousEnd); + const current = sumRows(currentRows); + const previous = sumRows(previousRows); + const metrics = latestDate && !salesResult.error + ? buildDashboardMetrics(current, previous) + : buildEmptyDashboardMetrics(); + + const spend = null; + const gmv = null; + const roi = null; + + const responseData = { + platform: 'pdd', + source: 'jiansun_database', + scope: shopName ? 'single-shop' : 'all-shops', + shopName: shopName || '全部店铺', + warnings, + period, + days, + latestDate, + startDate: currentStart, + previousStart, + previousEnd, + metrics, + trend: currentRows, + promotion: { + spend, + gmv, + roi, + clickCount: null, + orderCount: null, + }, + refunds: { + amount: null, + rate: null, + products: [], + }, + store: { + serviceDate: toDateString(serviceRows[0]?.createTime), + starDate: toDateString(starRows[0]?.createTime), + shopCount: Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount)), + star: starRows[0]?.mallStarTomms == null ? null : toNumber(starRows[0].mallStarTomms), + peerDescription: shopName + ? shopName + : `已汇总 ${Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount))} 家店铺`, + activityRank: starRows[0]?.gmvRk == null ? null : toNumber(starRows[0].gmvRk), + scoreRank: starRows[0]?.scoreRk == null ? null : toNumber(starRows[0].scoreRk), + scores: [ + { key: 'experience', label: '服务体验', value: serviceRows[0]?.cstmrServScore == null ? null : toNumber(serviceRows[0].cstmrServScore) }, + { key: 'product', label: '商品服务', value: serviceRows[0]?.spLmScore == null ? null : toNumber(serviceRows[0].spLmScore) }, + { key: 'logistics', label: '物流服务', value: serviceRows[0]?.wlLmScore == null ? null : toNumber(serviceRows[0].wlLmScore) }, + { key: 'shipping', label: '发货服务', value: serviceRows[0]?.fhLmScore == null ? null : toNumber(serviceRows[0].fhLmScore) }, + ], + }, + }; + dashboardCache.set(cacheKey, { data: responseData, expiresAt: Date.now() + DASHBOARD_CACHE_TTL_MS }); + settleDashboard({ data: responseData }); + dashboardInFlight.delete(cacheKey); + res.json({ success: true, data: responseData }); + } catch (error) { + settleDashboard?.({ error }); + if (activeCacheKey) dashboardInFlight.delete(activeCacheKey); + res.status(error.statusCode || 500).json({ success: false, error: error.message }); + } +}); + + +module.exports = router; diff --git a/backend/src/api/marketingEvents.js b/backend/src/api/marketingEvents.js deleted file mode 100644 index f7b2355..0000000 --- a/backend/src/api/marketingEvents.js +++ /dev/null @@ -1,268 +0,0 @@ -const express = require('express'); -const jianSunDb = require('../config/jianSunDatabase'); - -const router = express.Router(); - -const MAX_PAGE_SIZE = 100; - -function safeInt(value, fallback) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function normalizeSortField(field) { - const allowed = new Set([ - 'id', 'shopName', 'createTime', 'activity_name', 'activity_id', - 'goods_name', 'goods_id', 'min_activity_sku_price', 'max_activity_sku_price', - 'activity_goods_start_time', 'activity_goods_end_time', 'status', - ]); - return allowed.has(field) ? field : 'activity_goods_start_time'; -} - -function normalizeSortOrder(order) { - return String(order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; -} - -function escapeColumn(field) { - const allowed = new Set([ - 'id', 'shopName', 'createTime', 'business_type_str', 'activity_name', 'activity_id', - 'goods_name', 'goods_id', 'hd_thumb_url', 'enroll_time', 'min_activity_sku_price', - 'max_activity_sku_price', 'activity_goods_start_time', 'activity_goods_end_time', 'status', - ]); - if (!allowed.has(field)) return '[id]'; - return field === 'enroll_time' ? '[enroll_time]' : `[${field}]`; -} - -async function getSecondLatestDate() { - const rows = await jianSunDb.query( - `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate - FROM dbo.pdd_DataActivityRecords - WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' - ORDER BY createDate DESC` - ); - if (rows.length >= 2) return rows[1].createDate; - if (rows.length === 1) return rows[0].createDate; - return ''; -} - -router.get('/latest-date', async (req, res) => { - try { - const date = await getSecondLatestDate(); - res.json({ success: true, data: { date } }); - } catch (error) { - console.error('[MarketingEvents] 查询最新数据日期失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/list', async (req, res) => { - try { - const { - page = 1, - pageSize = 20, - sortBy = 'activity_goods_start_time', - sortOrder = 'DESC', - shopName, - activityType, - activityName, - activityId, - goodsName, - goodsId, - status, - date, - } = req.query; - - const currentPage = safeInt(page, 1); - const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE); - const offset = (currentPage - 1) * limit; - const sortColumn = normalizeSortField(sortBy); - const sortDir = normalizeSortOrder(sortOrder); - - const whereParts = []; - const params = {}; - - const filterDate = (date && String(date).trim()) || await getSecondLatestDate(); - if (filterDate) { - whereParts.push('LEFT([createTime], 10) = @filterDate'); - params.filterDate = filterDate; - } - - if (shopName && shopName.trim()) { - whereParts.push('[shopName] LIKE @shopName'); - params.shopName = `%${shopName.trim()}%`; - } - if (activityType && activityType.trim()) { - whereParts.push('[business_type_str] LIKE @activityType'); - params.activityType = `%${activityType.trim()}%`; - } - if (activityName && activityName.trim()) { - whereParts.push('[activity_name] LIKE @activityName'); - params.activityName = `%${activityName.trim()}%`; - } - if (activityId && String(activityId).trim()) { - whereParts.push('[activity_id] = @activityId'); - params.activityId = Number(activityId); - } - if (goodsName && goodsName.trim()) { - whereParts.push('[goods_name] LIKE @goodsName'); - params.goodsName = `%${goodsName.trim()}%`; - } - if (goodsId && String(goodsId).trim()) { - whereParts.push('[goods_id] = @goodsId'); - params.goodsId = String(goodsId).trim(); - } - if (status !== undefined && status !== '' && status !== 'all') { - whereParts.push('[status] = @status'); - params.status = Number(status); - } - - const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; - - const countResult = await jianSunDb.query( - `SELECT COUNT(*) AS total FROM dbo.pdd_DataActivityRecords ${whereSql}`, - params - ); - const total = countResult[0]?.total || 0; - - const dataResult = await jianSunDb.query( - `SELECT - [id], - [shopName], - [createTime], - [business_type_str] AS activityType, - [activity_name] AS activityName, - [activity_id] AS activityId, - [goods_name] AS goodsName, - [goods_id] AS goodsId, - [hd_thumb_url] AS thumbUrl, - [enroll_time] AS enrollTime, - TRY_CONVERT(decimal(18,2), [min_activity_sku_price]) / 100.0 AS minPrice, - TRY_CONVERT(decimal(18,2), [max_activity_sku_price]) / 100.0 AS maxPrice, - [activity_goods_start_time] AS activityStartTime, - [activity_goods_end_time] AS activityEndTime, - [status], - CASE WHEN [status] = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', [status]) END AS statusLabel - FROM dbo.pdd_DataActivityRecords - ${whereSql} - ORDER BY ${escapeColumn(sortColumn)} ${sortDir} - OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`, - { ...params, offset, limit } - ); - - const statusCountsResult = await jianSunDb.query( - `SELECT - [status], - COUNT(*) AS count - FROM dbo.pdd_DataActivityRecords - ${whereSql} - GROUP BY [status] - ORDER BY [status]`, - params - ); - - const statusCounts = {}; - let totalAll = 0; - statusCountsResult.forEach((row) => { - statusCounts[row.status] = row.count; - totalAll += row.count; - }); - - res.json({ - success: true, - data: { - list: dataResult, - total, - page: currentPage, - pageSize: limit, - filterDate, - statusCounts: { all: totalAll, ...statusCounts }, - }, - }); - } catch (error) { - console.error('[MarketingEvents] 查询活动记录失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/summary', async (req, res) => { - try { - const { date } = req.query; - const filterDate = (date && String(date).trim()) || await getSecondLatestDate(); - - const whereSql = filterDate ? 'WHERE LEFT([createTime], 10) = @filterDate' : ''; - const params = filterDate ? { filterDate } : {}; - - const [row] = await jianSunDb.query( - `SELECT - COUNT(*) AS totalRecords, - SUM(CASE WHEN [status] = 501 THEN 1 ELSE 0 END) AS successCount, - COUNT(DISTINCT TRY_CONVERT(varchar(50), [goods_id])) AS goodsCount, - COUNT(DISTINCT [shopName]) AS shopCount, - COUNT(DISTINCT [activity_id]) AS activityIdCount, - COUNT(DISTINCT [business_type_str]) AS activityTypeCount, - AVG(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS avgMinPrice, - AVG(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS avgMaxPrice, - MIN(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS globalMinPrice, - MAX(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS globalMaxPrice - FROM dbo.pdd_DataActivityRecords - ${whereSql}`, - params - ); - - const stats = row || {}; - const totalRecords = Number(stats.totalRecords) || 0; - const successCount = Number(stats.successCount) || 0; - const successRate = totalRecords > 0 ? ((successCount / totalRecords) * 100).toFixed(1) : '0.0'; - - res.json({ - success: true, - data: { - filterDate, - totalRecords, - successCount, - successRate, - goodsCount: Number(stats.goodsCount) || 0, - shopCount: Number(stats.shopCount) || 0, - activityIdCount: Number(stats.activityIdCount) || 0, - activityTypeCount: Number(stats.activityTypeCount) || 0, - avgMinPrice: stats.avgMinPrice != null ? Number(stats.avgMinPrice).toFixed(2) : '-', - avgMaxPrice: stats.avgMaxPrice != null ? Number(stats.avgMaxPrice).toFixed(2) : '-', - globalMinPrice: stats.globalMinPrice != null ? Number(stats.globalMinPrice).toFixed(2) : '-', - globalMaxPrice: stats.globalMaxPrice != null ? Number(stats.globalMaxPrice).toFixed(2) : '-', - }, - }); - } catch (error) { - console.error('[MarketingEvents] 查询汇总数据失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/status-counts', async (req, res) => { - try { - const result = await jianSunDb.query( - `SELECT - [status], - COUNT(*) AS count - FROM dbo.pdd_DataActivityRecords - GROUP BY [status] - ORDER BY [status]` - ); - - const statusCounts = {}; - let total = 0; - result.forEach((row) => { - statusCounts[row.status] = row.count; - total += row.count; - }); - - res.json({ - success: true, - data: { all: total, ...statusCounts }, - }); - } catch (error) { - console.error('[MarketingEvents] 查询状态统计失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -module.exports = router; diff --git a/backend/src/api/planningUpload.js b/backend/src/api/planningUpload.js index b87e400..14ea686 100644 --- a/backend/src/api/planningUpload.js +++ b/backend/src/api/planningUpload.js @@ -1,20 +1,9 @@ const express = require('express'); const { getResolvedUserWithPageAccess } = require('../services/pageAccess'); -const { getOpsBIncentiveBoard, getOpsBIncentiveBatches, getOpsBIncentiveDaily } = require('../services/opsBIncentiveBoardService'); const { getAlibaba1688Product } = require('../services/alibaba1688ProductService'); const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry'); const router = express.Router(); -async function ensurePlanningUploadAccess(req) { - const user = await getResolvedUserWithPageAccess(req.user.sub); - if (!user || (user.role !== 'admin' && !user.allowedPageKeys?.includes('ops_b_incentive'))) { - const error = new Error('当前账号没有激励看板权限'); - error.status = 403; - throw error; - } - return user; -} - async function ensureSelectionFissionAccess(req) { // Selection workflows are available to every authenticated user. They share // this router with template uploads, but must not inherit upload permissions. @@ -40,43 +29,4 @@ router.get('/selection-fission/1688-products/:productId', async (req, res) => { } }); -router.get('/analysis/ops-b-incentive/batches', async (req, res) => { - try { - await ensurePlanningUploadAccess(req); - const data = await getOpsBIncentiveBatches(); - res.json({ success: true, data }); - } catch (error) { - console.error('获取运营中心B激励看板批次列表失败:', error); - res.status(error.status || 500).json({ - error: error.message || '获取运营中心B激励看板批次列表失败', - }); - } -}); - -router.get('/analysis/ops-b-incentive', async (req, res) => { - try { - await ensurePlanningUploadAccess(req); - const result = await getOpsBIncentiveBoard(req.query || {}); - res.json({ success: true, data: result }); - } catch (error) { - console.error('获取运营中心B拼多多激励看板失败:', error); - res.status(error.status || 500).json({ - error: error.message || '获取运营中心B拼多多激励看板失败', - }); - } -}); - -router.get('/analysis/ops-b-incentive/daily', async (req, res) => { - try { - await ensurePlanningUploadAccess(req); - const result = await getOpsBIncentiveDaily(req.query || {}); - res.json({ success: true, data: result }); - } catch (error) { - console.error('获取运营中心B拼多多激励每日明细失败:', error); - res.status(error.status || 500).json({ - error: error.message || '获取运营中心B拼多多激励每日明细失败', - }); - } -}); - module.exports = router; diff --git a/backend/src/api/productAnalysis.js b/backend/src/api/productAnalysis.js deleted file mode 100644 index 464abf8..0000000 --- a/backend/src/api/productAnalysis.js +++ /dev/null @@ -1,1739 +0,0 @@ -const express = require('express'); -const database = require('../config/database'); -const jianSunDb = require('../config/jianSunDatabase'); -const middleDb = require('../config/middlePlatformDatabase'); -const planningDb = require('../services/planningDatabase'); -const { resolveQualityRefundSignal } = require('../services/productQualitySignal'); - -const router = express.Router(); - -const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily'; -const PRODUCT_SPREAD_TABLE = 'pdd_ReportGoodsSpread'; -const USER_PREFS_TABLE = 'product_analysis_user_prefs'; -const PRODUCT_SIGNAL_PREFS_KEY = 'productAnalysisSignalThresholds'; -const PRODUCT_META_CACHE_TTL_MS = 5 * 60 * 1000; -const PRODUCT_SOURCE_CACHE_TTL_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_SOURCE_CACHE_TTL_MS) || 60 * 1000, 10 * 1000); -const PRODUCT_SIGNAL_CACHE_TTL_MS = Math.max( - Number(process.env.PRODUCT_ANALYSIS_SIGNAL_CACHE_TTL_MS) || 5 * 60 * 1000, - 60 * 1000 -); -const PRODUCT_SIGNAL_WINDOW_DAYS = 30; -const PRODUCT_SIGNAL_OBSERVATION_DAYS = 7; -const PRODUCT_SIGNAL_STATUSES = new Set(['red', 'yellow', 'green']); -const DEFAULT_PRODUCT_SIGNAL_THRESHOLDS = Object.freeze({ - ctr: Object.freeze({ low: 3, high: 5 }), - conversion: Object.freeze({ low: 0.3, high: 0.5 }), -}); -const TAB_KEYS = new Set([ - 'refunds', - 'promotion', - 'activities', - 'navigator', - 'service-issues', - 'bad-reviews', - 'deductions', -]); - -const PRODUCT_SORT_FIELDS = Object.freeze({ - payOrdrAmt: 'payOrdrAmt', - orderNum: 'orderNum', - payOrdrUsrCnt: 'payOrdrUsrCnt', - payOrdrGoodsQty: 'payOrdrGoodsQty', - goodsUv: 'goodsUv', - goodsVcr: 'goodsVcr', - customerUnitPrice: 'customerUnitPrice', -}); -const DASHBOARD_PERIOD_DAYS = Object.freeze({ day: 1, week: 7, month: 30 }); -const DASHBOARD_CACHE_TTL_MS = 60 * 1000; -const DASHBOARD_QUERY_TIMEOUT_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000); -const dashboardCache = new Map(); -const dashboardInFlight = new Map(); -const productSignalCache = new Map(); -const productSignalItemCache = new Map(); -const productSignalSnapshotCache = new Map(); -const productSignalSnapshotInFlight = new Map(); -const productSourceCache = new Map(); -const productSourceInFlight = new Map(); -let productMetaCache = null; -let productMetaInFlight = null; -let productDateCache = null; -let productDateInFlight = null; -let productMappingCache = null; -let productMappingInFlight = null; - -function positiveInt(value, fallback, max = 200) { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed < 1) return fallback; - return Math.min(parsed, max); -} - -function toNumber(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function normalizeDate(value) { - const text = String(value || '').trim(); - return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; -} - -function toDateString(value) { - if (!value) return ''; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10); -} - -function toLocalDateString(value) { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return null; - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${date.getFullYear()}-${month}-${day}`; -} - -function addDays(value, offset) { - const date = new Date(`${value}T00:00:00Z`); - date.setUTCDate(date.getUTCDate() + offset); - return date.toISOString().slice(0, 10); -} - -function inclusiveDayCount(startDate, endDate) { - const start = new Date(`${startDate}T00:00:00Z`); - const end = new Date(`${endDate}T00:00:00Z`); - return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; -} - -function percentChange(current, previous) { - if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0) return null; - return ((current - previous) * 100) / Math.abs(previous); -} - -function sumRows(rows) { - return rows.reduce((result, row) => { - result.payAmount += toNumber(row.payAmount); - result.buyerCount += toNumber(row.buyerCount); - result.visitorCount += toNumber(row.visitorCount); - result.goodsPv += toNumber(row.goodsPv); - result.payGoodsQty += toNumber(row.payGoodsQty); - result.payOrderCount += toNumber(row.payOrderCount); - result.favoriteCount += toNumber(row.favoriteCount); - result.impressionCount += toNumber(row.impressionCount); - return result; - }, { - payAmount: 0, - buyerCount: 0, - visitorCount: 0, - goodsPv: 0, - payGoodsQty: 0, - payOrderCount: 0, - favoriteCount: 0, - impressionCount: 0, - }); -} - -async function runDashboardQuery(name, sqlText, params = {}) { - try { - return { - name, - rows: await jianSunDb.query(sqlText, params, { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }), - error: null, - }; - } catch (error) { - console.warn(`[product-analysis/dashboard] ${name} query failed:`, error.message); - return { name, rows: [], error }; - } -} - -function buildDashboardMetrics(current, previous) { - const conversionRate = current.visitorCount > 0 - ? (current.buyerCount * 100) / current.visitorCount - : null; - const previousConversionRate = previous.visitorCount > 0 - ? (previous.buyerCount * 100) / previous.visitorCount - : null; - return [ - { key: 'payAmount', label: '支付金额', value: current.payAmount, previous: previous.payAmount, change: percentChange(current.payAmount, previous.payAmount), format: 'money' }, - { key: 'payOrderCount', label: '支付订单数', value: Math.round(current.payOrderCount), previous: Math.round(previous.payOrderCount), change: percentChange(current.payOrderCount, previous.payOrderCount), format: 'integer' }, - { key: 'visitorCount', label: '访客数', value: Math.round(current.visitorCount), previous: Math.round(previous.visitorCount), change: percentChange(current.visitorCount, previous.visitorCount), format: 'integer' }, - { key: 'buyerCount', label: '支付买家数', value: Math.round(current.buyerCount), previous: Math.round(previous.buyerCount), change: percentChange(current.buyerCount, previous.buyerCount), format: 'integer' }, - { key: 'conversionRate', label: '支付转化率', value: conversionRate, previous: previousConversionRate, change: percentChange(conversionRate, previousConversionRate), format: 'percent' }, - { key: 'payGoodsQty', label: '支付件数', value: Math.round(current.payGoodsQty), previous: Math.round(previous.payGoodsQty), change: percentChange(current.payGoodsQty, previous.payGoodsQty), format: 'integer' }, - { key: 'favoriteCount', label: '收藏人数', value: Math.round(current.favoriteCount), previous: Math.round(previous.favoriteCount), change: percentChange(current.favoriteCount, previous.favoriteCount), format: 'integer' }, - { key: 'goodsPv', label: '商品浏览量', value: Math.round(current.goodsPv), previous: Math.round(previous.goodsPv), change: percentChange(current.goodsPv, previous.goodsPv), format: 'integer' }, - ]; -} - -function buildEmptyDashboardMetrics() { - return [ - { key: 'payAmount', label: '支付金额', value: null, previous: null, change: null, format: 'money' }, - { key: 'payOrderCount', label: '支付订单数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'visitorCount', label: '访客数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'buyerCount', label: '支付买家数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'conversionRate', label: '支付转化率', value: null, previous: null, change: null, format: 'percent' }, - { key: 'payGoodsQty', label: '支付件数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'favoriteCount', label: '收藏人数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'goodsPv', label: '商品浏览量', value: null, previous: null, change: null, format: 'integer' }, - ]; -} - -function normalizeDepartments(...values) { - const result = []; - values.filter(Boolean).forEach((value) => { - if (Array.isArray(value)) { - result.push(...value); - return; - } - - const text = String(value).trim(); - if (!text) return; - - try { - const parsed = JSON.parse(text); - if (Array.isArray(parsed)) { - result.push(...parsed); - return; - } - } catch { - // Fall back to comma-separated legacy values. - } - - result.push(...text.split(/[,,]/)); - }); - - return [...new Set(result.map((item) => String(item || '').trim()).filter(Boolean))]; -} - -async function getDataScope(userId) { - const rows = await database.local.query( - `SELECT u.id, u.role, u.department_names, d.name AS department_name - FROM users u - LEFT JOIN departments d ON d.id = u.department_id - WHERE u.id = ? - LIMIT 1`, - [userId] - ); - const user = rows[0]; - if (!user) return null; - - return { - isAllData: user.role === 'admin', - departments: normalizeDepartments(user.department_names, user.department_name), - }; -} - -function appendScope(whereParts, params, scope) { - if (scope.isAllData) return; - if (!scope.departments.length) { - whereParts.push('1 = 0'); - return; - } - - whereParts.push(`ownerDept IN (${scope.departments.map(() => '?').join(', ')})`); - params.push(...scope.departments); -} - -async function resolveSnapshotDate(requestedDate) { - const normalized = normalizeDate(requestedDate); - if (normalized) return normalized; - - const rows = await planningDb.query( - `SELECT DATE_FORMAT(MAX(statDate), '%Y-%m-%d') AS latestDate - FROM ${PRODUCT_SNAPSHOT_TABLE}` - ); - return rows[0]?.latestDate || ''; -} - -async function resolveDateRange(startValue, endValue) { - const requestedStartDate = normalizeDate(startValue); - const requestedEndDate = normalizeDate(endValue); - const dateRange = requestedStartDate && requestedEndDate ? null : await loadProductDateRange(); - const latestDate = dateRange?.latestDate || requestedEndDate || requestedStartDate || ''; - const minDate = dateRange?.minDate || requestedStartDate || latestDate; - const startDate = requestedStartDate || latestDate; - const endDate = requestedEndDate || latestDate; - return { - minDate, - latestDate, - startDate: startDate <= endDate ? startDate : endDate, - endDate: startDate <= endDate ? endDate : startDate, - }; -} - -function getRequestUserId(req) { - const userId = Number(req.user?.sub || req.user?.id || req.user?.userId || 0); - return Number.isSafeInteger(userId) && userId > 0 ? userId : null; -} - -function normalizeProductSignalThresholds(value) { - const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - const normalizeMetric = (metric, defaults) => { - const low = Number(metric?.low); - const high = Number(metric?.high); - return { - low: Number.isFinite(low) ? Math.min(100, Math.max(0, low)) : defaults.low, - high: Number.isFinite(high) ? Math.min(100, Math.max(0, high)) : defaults.high, - }; - }; - return { - ctr: normalizeMetric(source.ctr, DEFAULT_PRODUCT_SIGNAL_THRESHOLDS.ctr), - conversion: normalizeMetric(source.conversion, DEFAULT_PRODUCT_SIGNAL_THRESHOLDS.conversion), - }; -} - -function parseUserPrefsStorage(value) { - try { - const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value; - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -function readProductSignalThresholds(value) { - const storage = parseUserPrefsStorage(value); - const saved = storage[PRODUCT_SIGNAL_PREFS_KEY]; - const configured = Boolean(saved && typeof saved === 'object' && !Array.isArray(saved)); - return { - configured, - thresholds: normalizeProductSignalThresholds(configured ? saved : null), - }; -} - -function validateProductSignalThresholds(thresholds) { - if (thresholds.ctr.low >= thresholds.ctr.high) { - return '点击率的黄绿分界值必须大于红黄分界值'; - } - if (thresholds.conversion.low >= thresholds.conversion.high) { - return '转化率的黄绿分界值必须大于红黄分界值'; - } - return ''; -} - -async function persistProductSignalThresholds(userId, thresholds) { - const patch = JSON.stringify({ [PRODUCT_SIGNAL_PREFS_KEY]: thresholds }); - try { - await database.local.query( - `INSERT INTO ${USER_PREFS_TABLE} (user_id, prefs_json) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH( - IF(JSON_VALID(prefs_json), prefs_json, '{}'), - VALUES(prefs_json) - )`, - [userId, patch] - ); - } catch (error) { - const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET'] - .includes(error.code); - if (!canFallback) throw error; - - const rows = await database.local.query( - `SELECT prefs_json FROM ${USER_PREFS_TABLE} WHERE user_id = ? LIMIT 1`, - [userId] - ); - const storage = parseUserPrefsStorage(rows[0]?.prefs_json); - storage[PRODUCT_SIGNAL_PREFS_KEY] = thresholds; - await database.local.query( - `INSERT INTO ${USER_PREFS_TABLE} (user_id, prefs_json) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`, - [userId, JSON.stringify(storage)] - ); - } -} - -async function loadUserProductSignalThresholds(userId) { - const rows = await database.local.query( - `SELECT prefs_json - FROM ${USER_PREFS_TABLE} - WHERE user_id = ? - LIMIT 1`, - [userId] - ); - return readProductSignalThresholds(rows[0]?.prefs_json); -} - -function resolveProductSignalStatus(signal, thresholds) { - const statuses = []; - if (signal?.signalWindowEndDate && !signal.signalIsObserving) { - const ctr = toNumber(signal.signalCtr); - const conversion = toNumber(signal.signalClickConversionRate); - statuses.push(ctr < thresholds.ctr.low - ? 'red' - : ctr < thresholds.ctr.high ? 'yellow' : 'green'); - statuses.push(conversion < thresholds.conversion.low - ? 'red' - : conversion < thresholds.conversion.high ? 'yellow' : 'green'); - } - if (signal?.signalQualityStatus) statuses.push(signal.signalQualityStatus); - if (statuses.includes('red')) return 'red'; - if (statuses.includes('yellow')) return 'yellow'; - return statuses.includes('green') ? 'green' : ''; -} - -async function loadProductDateRange() { - if (productDateCache?.expiresAt > Date.now()) return productDateCache.data; - if (productDateInFlight) return productDateInFlight; - - productDateInFlight = middleDb.query( - `SELECT MIN(createTime) AS minDate, MAX(createTime) AS latestDate - FROM dbo.pdd_DataGoods` - ).then((rows) => { - const latestDate = normalizeDate(rows[0]?.latestDate); - const data = { - latestDate, - minDate: normalizeDate(rows[0]?.minDate) || latestDate, - }; - productDateCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; - return data; - }).finally(() => { - productDateInFlight = null; - }); - - return productDateInFlight; -} - -async function loadProductMeta() { - if (productMetaCache?.expiresAt > Date.now()) return productMetaCache.data; - if (productMetaInFlight) return productMetaInFlight; - - productMetaInFlight = Promise.all([ - loadProductDateRange(), - middleDb.query( - `SELECT DISTINCT shopName - FROM dbo.pdd_DataGoods - WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' - ORDER BY shopName` - ), - loadProductMapping(), - ]).then(([dateRange, shopRows, mappingRows]) => { - const owners = [...new Set([...mappingRows.values()] - .map((row) => String(row.idOwner || '').trim()) - .filter(Boolean))] - .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN')); - const data = { - platform: 'pdd', - ...dateRange, - shops: shopRows.map((row) => row.shopName), - owners, - }; - productMetaCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; - return data; - }).finally(() => { - productMetaInFlight = null; - }); - - return productMetaInFlight; -} - -async function loadProductMapping() { - if (productMappingCache?.expiresAt > Date.now()) return productMappingCache.data; - if (productMappingInFlight) return productMappingInFlight; - - productMappingInFlight = planningDb.query( - `SELECT m.goodsId, m.spu, m.skuCode, m.idOwner, m.ownerDept, m.spend, m.isPriceBreak, - f.first_created_at AS firstCreatedAt - FROM ${PRODUCT_SNAPSHOT_TABLE} m - LEFT JOIN pdd_goods_first_seen f ON f.goods_id = m.goodsId - WHERE m.statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})` - ).then((rows) => { - const data = new Map(rows.map((row) => [String(row.goodsId), { - ...row, - firstCreatedAt: toLocalDateString(row.firstCreatedAt), - }])); - productMappingCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; - return data; - }).finally(() => { - productMappingInFlight = null; - }); - - return productMappingInFlight; -} - -function pruneProductSourceCache() { - if (productSourceCache.size <= 30) return; - const now = Date.now(); - for (const [key, entry] of productSourceCache) { - if (entry.expiresAt <= now || productSourceCache.size > 20) productSourceCache.delete(key); - } -} - -async function loadProductSourceRows(startDate, endDate, shopName) { - const cacheKey = JSON.stringify({ startDate, endDate, shopName }); - const cached = productSourceCache.get(cacheKey); - if (cached?.expiresAt > Date.now()) return cached.data; - if (productSourceInFlight.has(cacheKey)) return productSourceInFlight.get(cacheKey); - - const whereParts = [ - 'createTime >= @startDate', - 'createTime <= @endDate', - 'goodsId IS NOT NULL', - ]; - const params = { startDate, endDate }; - if (shopName) { - whereParts.push('shopName = @shopName'); - params.shopName = shopName; - } - - const pending = middleDb.query( - `SELECT - CONVERT(varchar(50), goodsId) AS goodsId, - shopName, - MAX(goodsName) AS goodsName, - MAX(hdThumbUrl) AS hdThumbUrl, - MAX(cate1Name) AS cate1Name, - MAX(cate2Name) AS cate2Name, - MAX(cate3Name) AS cate3Name, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsUv), 0)) AS goodsUv, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsPv), 0)) AS goodsPv, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrAmt), 0)) AS payOrdrAmt, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrGoodsQty), 0)) AS payOrdrGoodsQty, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrCnt), 0)) AS orderNum, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrUsrCnt), 0)) AS payOrdrUsrCnt, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsFavCnt), 0)) AS goodsFavCnt, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), imprUsrCnt), 0)) AS imprUsrCnt - FROM dbo.pdd_DataGoods - WHERE ${whereParts.join(' AND ')} - GROUP BY shopName, goodsId`, - params - ).then((rows) => { - productSourceCache.set(cacheKey, { - data: rows, - expiresAt: Date.now() + PRODUCT_SOURCE_CACHE_TTL_MS, - }); - pruneProductSourceCache(); - return rows; - }).finally(() => { - productSourceInFlight.delete(cacheKey); - }); - - productSourceInFlight.set(cacheKey, pending); - return pending; -} - -function buildProductSignalRecord(firstObservedDate, spread, signalStartDate, signalEndDate) { - const normalizedFirstObservedDate = normalizeDate(firstObservedDate); - const observedDays = normalizedFirstObservedDate - ? Math.max(1, inclusiveDayCount(normalizedFirstObservedDate, signalEndDate)) - : null; - const clicks = toNumber(spread?.clicks); - const impressions = toNumber(spread?.impressions); - const orders = toNumber(spread?.orders); - return { - signalWindowStartDate: signalStartDate, - signalWindowEndDate: signalEndDate, - signalFirstObservedDate: normalizedFirstObservedDate || null, - signalObservedDays: observedDays, - signalIsObserving: observedDays !== null && observedDays <= PRODUCT_SIGNAL_OBSERVATION_DAYS, - signalCtr: impressions > 0 ? (clicks * 100) / impressions : 0, - signalClickConversionRate: clicks > 0 ? (orders * 100) / clicks : 0, - signalImpressions: impressions, - signalClicks: clicks, - signalOrders: orders, - }; -} - -function buildProductQualitySignalRecord(row) { - if (!row) return {}; - const ranking = resolveQualityRefundSignal(row.secondaryStage, row.tertiaryStage); - const refundRate = Number(row.qualityRefundRate); - return { - signalQualityStatDate: normalizeDate(row.qualityStatDate) || null, - signalQualityRefundRate: Number.isFinite(refundRate) ? refundRate : null, - signalQualitySecondaryStage: Number(row.secondaryStage) || null, - signalQualityTertiaryStage: Number(row.tertiaryStage) || null, - signalQualitySecondaryLabel: ranking.secondaryLabel, - signalQualityTertiaryLabel: ranking.tertiaryLabel, - signalQualityMainCategoryLabel: ranking.mainCategoryLabel, - signalQualityStatus: ranking.status, - signalQualityReason: ranking.reason, - }; -} - -function mergeProductQualitySignals(data, qualityRows) { - qualityRows.forEach((row) => { - const goodsId = String(row.goodsId); - data.set(goodsId, { - ...(data.get(goodsId) || {}), - ...buildProductQualitySignalRecord(row), - }); - }); - return data; -} - -async function loadProductSignalSnapshot(endDate) { - const normalizedEndDate = normalizeDate(endDate); - if (!normalizedEndDate) return new Map(); - const signalStartDate = addDays(normalizedEndDate, -(PRODUCT_SIGNAL_WINDOW_DAYS - 1)); - const cacheKey = `${signalStartDate}:${normalizedEndDate}`; - const cached = productSignalSnapshotCache.get(cacheKey); - if (cached?.expiresAt > Date.now()) return cached.data; - if (productSignalSnapshotInFlight.has(cacheKey)) return productSignalSnapshotInFlight.get(cacheKey); - - const params = { signalStartDate, signalEndDate: normalizedEndDate }; - const qualityEndExclusive = addDays(normalizedEndDate, 1); - const pending = Promise.all([ - middleDb.query( - `SELECT CONVERT(varchar(50), goodsId) AS goodsId, MIN(createTime) AS firstObservedDate - FROM dbo.pdd_DataGoods - WHERE createTime >= @signalStartDate - AND createTime <= @signalEndDate - AND goodsId IS NOT NULL - GROUP BY goodsId`, - params - ), - middleDb.query( - `SELECT CONVERT(varchar(50), goodsId) AS goodsId, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), [click]), 0)) AS clicks, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), impression), 0)) AS impressions, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), orderNum), 0)) AS orders - FROM dbo.${PRODUCT_SPREAD_TABLE} - WHERE createTime >= @signalStartDate - AND createTime <= @signalEndDate - AND goodsId IS NOT NULL - GROUP BY goodsId`, - params - ), - jianSunDb.query( - `WITH qualityGoods AS ( - SELECT DISTINCT goodsId - FROM dbo.pdd_GoodsQualities - WHERE goodsId IS NOT NULL - ) - SELECT CONVERT(varchar(50), qualityGoods.goodsId) AS goodsId, - latestQuality.createTime AS qualityStatDate, - latestQuality.qltyRfndOrdrCntRto1m AS qualityRefundRate, - latestQuality.rankQltyRfndOrdrCntRto1mStage AS secondaryStage, - latestQuality.rankQltyRfndOrdrCntRto1mStplStage AS tertiaryStage - FROM qualityGoods - CROSS APPLY ( - SELECT TOP (1) - createTime, - qltyRfndOrdrCntRto1m, - rankQltyRfndOrdrCntRto1mStage, - rankQltyRfndOrdrCntRto1mStplStage - FROM dbo.pdd_GoodsQualities - WHERE goodsId = qualityGoods.goodsId - AND createTime < @qualityEndExclusive - ORDER BY createTime DESC, id DESC - ) AS latestQuality`, - { qualityEndExclusive } - ), - ]).then(([listingRows, spreadRows, qualityRows]) => { - const listingMap = new Map(listingRows.map((row) => [String(row.goodsId), row.firstObservedDate])); - const spreadMap = new Map(spreadRows.map((row) => [String(row.goodsId), row])); - const goodsIds = new Set([...listingMap.keys(), ...spreadMap.keys()]); - const data = new Map([...goodsIds].map((goodsId) => [ - goodsId, - buildProductSignalRecord( - listingMap.get(goodsId), - spreadMap.get(goodsId), - signalStartDate, - normalizedEndDate - ), - ])); - mergeProductQualitySignals(data, qualityRows); - productSignalSnapshotCache.set(cacheKey, { - data, - expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS, - }); - if (productSignalSnapshotCache.size > 5) { - const now = Date.now(); - for (const [key, entry] of productSignalSnapshotCache) { - if (entry.expiresAt <= now || productSignalSnapshotCache.size > 3) { - productSignalSnapshotCache.delete(key); - } - } - } - return data; - }).finally(() => { - productSignalSnapshotInFlight.delete(cacheKey); - }); - - productSignalSnapshotInFlight.set(cacheKey, pending); - return pending; -} - -async function loadProductSignals(goodsIds, endDate) { - const normalizedGoodsIds = [...new Set(goodsIds.map((value) => String(value || '').trim()).filter(Boolean))]; - const normalizedEndDate = normalizeDate(endDate); - if (!normalizedGoodsIds.length || !normalizedEndDate) return new Map(); - - const signalStartDate = addDays(normalizedEndDate, -(PRODUCT_SIGNAL_WINDOW_DAYS - 1)); - const result = new Map(); - const missingGoodsIds = []; - const now = Date.now(); - normalizedGoodsIds.forEach((goodsId) => { - const itemCacheKey = `${signalStartDate}:${normalizedEndDate}:${goodsId}`; - const cachedItem = productSignalItemCache.get(itemCacheKey); - if (cachedItem?.expiresAt > now) { - result.set(goodsId, cachedItem.data); - } else { - missingGoodsIds.push(goodsId); - } - }); - if (!missingGoodsIds.length) return result; - - const cacheKey = `${signalStartDate}:${normalizedEndDate}:${[...missingGoodsIds].sort().join(',')}`; - const cached = productSignalCache.get(cacheKey); - if (cached?.expiresAt > now) { - cached.data.forEach((value, key) => { - productSignalItemCache.set(`${signalStartDate}:${normalizedEndDate}:${key}`, { - data: value, - expiresAt: cached.expiresAt, - }); - result.set(key, value); - }); - return result; - } - - const params = { signalStartDate, signalEndDate: normalizedEndDate }; - const qualityParams = { qualityEndExclusive: addDays(normalizedEndDate, 1) }; - const placeholders = missingGoodsIds.map((goodsId, index) => { - const key = `signalGoodsId${index}`; - params[key] = goodsId; - qualityParams[key] = goodsId; - return `@${key}`; - }).join(', '); - - const [listingRows, spreadRows, qualityRows] = await Promise.all([ - middleDb.query( - `SELECT CONVERT(varchar(50), goodsId) AS goodsId, MIN(createTime) AS firstObservedDate - FROM dbo.pdd_DataGoods - WHERE createTime >= @signalStartDate - AND createTime <= @signalEndDate - AND goodsId IN (${placeholders}) - GROUP BY goodsId`, - params - ), - middleDb.query( - `SELECT CONVERT(varchar(50), goodsId) AS goodsId, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), [click]), 0)) AS clicks, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), impression), 0)) AS impressions, - SUM(COALESCE(TRY_CONVERT(decimal(18, 2), orderNum), 0)) AS orders - FROM dbo.${PRODUCT_SPREAD_TABLE} - WHERE createTime >= @signalStartDate - AND createTime <= @signalEndDate - AND goodsId IN (${placeholders}) - GROUP BY goodsId`, - params - ), - jianSunDb.query( - `WITH latestQuality AS ( - SELECT CONVERT(varchar(50), goodsId) AS goodsId, - createTime AS qualityStatDate, - qltyRfndOrdrCntRto1m AS qualityRefundRate, - rankQltyRfndOrdrCntRto1mStage AS secondaryStage, - rankQltyRfndOrdrCntRto1mStplStage AS tertiaryStage, - ROW_NUMBER() OVER ( - PARTITION BY goodsId - ORDER BY createTime DESC, id DESC - ) AS rowNumber - FROM dbo.pdd_GoodsQualities - WHERE goodsId IN (${placeholders}) - AND createTime < @qualityEndExclusive - ) - SELECT goodsId, qualityStatDate, qualityRefundRate, secondaryStage, tertiaryStage - FROM latestQuality - WHERE rowNumber = 1`, - qualityParams - ), - ]); - - const listingMap = new Map(listingRows.map((row) => [String(row.goodsId), normalizeDate(row.firstObservedDate)])); - const spreadMap = new Map(spreadRows.map((row) => [String(row.goodsId), row])); - const data = new Map(missingGoodsIds.map((goodsId) => [ - goodsId, - buildProductSignalRecord( - listingMap.get(goodsId), - spreadMap.get(goodsId), - signalStartDate, - normalizedEndDate - ), - ])); - mergeProductQualitySignals(data, qualityRows); - - productSignalCache.set(cacheKey, { data, expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS }); - if (productSignalCache.size > 100) { - const now = Date.now(); - for (const [key, entry] of productSignalCache) { - if (entry.expiresAt <= now || productSignalCache.size > 80) productSignalCache.delete(key); - } - } - data.forEach((value, key) => { - productSignalItemCache.set(`${signalStartDate}:${normalizedEndDate}:${key}`, { - data: value, - expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS, - }); - result.set(key, value); - }); - if (productSignalItemCache.size > 500) { - const itemNow = Date.now(); - for (const [key, entry] of productSignalItemCache) { - if (entry.expiresAt <= itemNow || productSignalItemCache.size > 400) productSignalItemCache.delete(key); - } - } - return result; -} - -function asNumericRow(row) { - const numericKeys = [ - 'goodsUv', 'goodsPv', 'payOrdrAmt', 'payOrdrGoodsQty', 'orderNum', - 'payOrdrUsrCnt', 'goodsVcr', 'goodsFavCnt', 'imprUsrCnt', - ]; - const result = { ...row }; - numericKeys.forEach((key) => { - result[key] = Number(row[key] || 0); - }); - return result; -} - -function buildTabPayload(tabKey, product, history) { - const historyRows = [...history].reverse(); - const common = { - tabKey, - product, - source: PRODUCT_SNAPSHOT_TABLE, - }; - - if (tabKey === 'activities') { - return { - ...common, - status: 'empty', - message: '店铺后台宝贝活动数据源待接入', - metrics: [], - trend: [], - columns: [], - rows: [], - }; - } - - if (tabKey === 'deductions') { - return { - ...common, - status: 'empty', - message: '店铺后台账单订单扣款数据源待接入', - metrics: [], - trend: [], - columns: [], - rows: [], - }; - } - - if (tabKey === 'refunds') { - return { - ...common, - status: 'ready', - metrics: [ - { key: 'afterSaleCount', label: '售后单量', value: product.afterSaleCount }, - { key: 'afterSaleRate', label: '售后率', value: product.afterSaleRate, unit: '%' }, - { key: 'payOrdrAmt', label: '销售额', value: product.payOrdrAmt, unit: '¥' }, - { key: 'orderNum', label: '订单量', value: product.orderNum }, - ], - trend: historyRows.map((row) => ({ - date: row.statDate, - primary: row.afterSaleCount, - secondary: row.afterSaleRate, - })), - trendLabels: { primary: '售后单量', secondary: '售后率' }, - columns: [ - { key: 'statDate', title: '数据日期' }, - { key: 'afterSaleCount', title: '售后单量' }, - { key: 'afterSaleRate', title: '售后率' }, - { key: 'payOrdrAmt', title: '销售额' }, - { key: 'orderNum', title: '订单量' }, - ], - rows: history, - }; - } - - if (tabKey === 'promotion') { - return { - ...common, - status: 'ready', - metrics: [ - { key: 'spend', label: '推广花费', value: product.spend, unit: '¥' }, - { key: 'feeRatio', label: '真实费比', value: product.feeRatio, unit: '%' }, - { key: 'payOrdrAmt', label: '销售额', value: product.payOrdrAmt, unit: '¥' }, - { key: 'goodsVcr', label: '成交转化率', value: product.goodsVcr, unit: '%' }, - ], - trend: historyRows.map((row) => ({ - date: row.statDate, - primary: row.spend, - secondary: row.payOrdrAmt, - })), - trendLabels: { primary: '推广花费', secondary: '销售额' }, - columns: [ - { key: 'statDate', title: '数据日期' }, - { key: 'spend', title: '推广花费' }, - { key: 'feeRatio', title: '真实费比' }, - { key: 'payOrdrAmt', title: '销售额' }, - { key: 'goodsVcr', title: '成交转化率' }, - ], - rows: history, - }; - } - - if (tabKey === 'navigator') { - return { - ...common, - status: 'ready', - metrics: [ - { key: 'goodsUv', label: '访客数', value: product.goodsUv }, - { key: 'ctr', label: '点击率', value: product.ctr, unit: '%' }, - { key: 'clickConversionRate', label: '点击转化率', value: product.clickConversionRate, unit: '%' }, - { key: 'goodsVcr', label: '成交转化率', value: product.goodsVcr, unit: '%' }, - ], - trend: historyRows.map((row) => ({ - date: row.statDate, - primary: row.goodsUv, - secondary: row.goodsVcr, - })), - trendLabels: { primary: '访客数', secondary: '成交转化率' }, - columns: [ - { key: 'statDate', title: '数据日期' }, - { key: 'goodsUv', title: '访客数' }, - { key: 'ctr', title: '点击率' }, - { key: 'clickConversionRate', title: '点击转化率' }, - { key: 'goodsVcr', title: '成交转化率' }, - { key: 'imprUsrCnt', title: '流失指数' }, - { key: 'unpaidUserCount', title: '未支付人数' }, - ], - rows: history, - }; - } - - return { - ...common, - status: 'ready', - metrics: [ - { key: 'badReviewCount', label: '差评数量', value: product.badReviewCount }, - { key: 'afterSaleCount', label: '售后单量', value: product.afterSaleCount }, - { key: 'afterSaleRate', label: '售后率', value: product.afterSaleRate, unit: '%' }, - { key: 'payOrdrUsrCnt', label: '成交买家数', value: product.payOrdrUsrCnt }, - ], - trend: historyRows.map((row) => ({ - date: row.statDate, - primary: row.badReviewCount, - secondary: row.afterSaleCount, - })), - trendLabels: { primary: '差评数量', secondary: '售后单量' }, - columns: [ - { key: 'statDate', title: '数据日期' }, - { key: 'badReviewCount', title: '差评数量' }, - { key: 'afterSaleCount', title: '售后单量' }, - { key: 'afterSaleRate', title: '售后率' }, - { key: 'payOrdrUsrCnt', title: '成交买家数' }, - ], - rows: history, - }; -} - -async function buildActivityPayload(product, startDate, endDate) { - const startTimestamp = new Date(`${startDate}T00:00:00+08:00`).getTime(); - const endTimestamp = new Date(`${endDate}T23:59:59.999+08:00`).getTime(); - const rows = await jianSunDb.query( - `SELECT TOP (200) - id, - business_type_str AS activityType, - activity_name AS activityName, - activity_id AS activityId, - CONVERT(varchar(19), DATEADD( - SECOND, - (TRY_CONVERT(bigint, [enroll_time ]) / 1000) % 86400, - DATEADD(DAY, (TRY_CONVERT(bigint, [enroll_time ]) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) - ), 120) AS enrollTime, - TRY_CONVERT(decimal(18, 2), min_activity_sku_price) / 100.0 AS minPrice, - TRY_CONVERT(decimal(18, 2), max_activity_sku_price) / 100.0 AS maxPrice, - CONVERT(varchar(19), DATEADD( - SECOND, - (TRY_CONVERT(bigint, activity_goods_start_time) / 1000) % 86400, - DATEADD(DAY, (TRY_CONVERT(bigint, activity_goods_start_time) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) - ), 120) AS activityStartTime, - CONVERT(varchar(19), DATEADD( - SECOND, - (TRY_CONVERT(bigint, activity_goods_end_time) / 1000) % 86400, - DATEADD(DAY, (TRY_CONVERT(bigint, activity_goods_end_time) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) - ), 120) AS activityEndTime, - status, - CASE WHEN status = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', status) END AS statusLabel - FROM dbo.pdd_DataActivityRecords - WHERE TRY_CONVERT(bigint, goods_id) = @goodsId - AND ( - (TRY_CONVERT(bigint, activity_goods_end_time) >= @startTimestamp - AND TRY_CONVERT(bigint, activity_goods_start_time) <= @endTimestamp) - OR TRY_CONVERT(date, createTime) BETWEEN @startDate AND @endDate - ) - ORDER BY TRY_CONVERT(bigint, activity_goods_start_time) DESC, id DESC`, - { - goodsId: product.goodsId, - startTimestamp, - endTimestamp, - startDate, - endDate, - } - ); - - if (!rows.length) { - return { - tabKey: 'activities', - product, - source: 'JianSun.dbo.pdd_DataActivityRecords', - status: 'empty', - message: '当前商品在所选区间暂无活动报名数据', - metrics: [], - trend: [], - columns: [], - rows: [], - }; - } - - const successCount = rows.filter((row) => Number(row.status) === 501).length; - const activityTypes = new Set(rows.map((row) => row.activityType).filter(Boolean)).size; - return { - tabKey: 'activities', - product, - source: 'JianSun.dbo.pdd_DataActivityRecords', - status: 'ready', - metrics: [ - { key: 'activityCount', label: '活动记录', value: rows.length }, - { key: 'successCount', label: '报名成功', value: successCount }, - { key: 'activityTypes', label: '活动类型', value: activityTypes }, - { key: 'successRate', label: '报名成功率', value: (successCount * 100) / rows.length, unit: '%' }, - ], - trend: [], - columns: [ - { key: 'activityName', title: '活动名称' }, - { key: 'activityType', title: '活动类型' }, - { key: 'enrollTime', title: '提交时间' }, - { key: 'activityStartTime', title: '活动开始时间' }, - { key: 'activityEndTime', title: '活动结束时间' }, - { key: 'minPrice', title: '活动最低价' }, - { key: 'maxPrice', title: '活动最高价' }, - { key: 'statusLabel', title: '状态' }, - ], - rows, - }; -} - -async function buildQualityPayload(product, endDate) { - const rows = await jianSunDb.query( - `SELECT TOP (30) - id, - CONVERT(varchar(10), TRY_CONVERT(date, createTime), 23) AS statDate, - avgDescRevScr1m, - qltyRfndOrdrCntRto1m, - ptHelpPzRate30d, - midRto30d, - displayMallScoreRk, - rankAvgDescRevScr1mStage, - rankQltyRfndOrdrCntRto1mStage, - rankQltyRfndOrdrCntRto1mStplStage, - rankPtHelpPzRate30dStage, - [rankComscoreStage ] AS rankComscoreStage, - goodsScoreRkStage - FROM dbo.pdd_GoodsQualities - WHERE goodsId = @goodsId - AND createTime < @qualityEndExclusive - ORDER BY createTime DESC, id DESC`, - { goodsId: product.goodsId, qualityEndExclusive: addDays(endDate, 1) } - ); - - if (!rows.length) { - return { - tabKey: 'navigator', - product, - source: 'JianSun.dbo.pdd_GoodsQualities', - status: 'empty', - message: '当前商品暂无品质领航员数据', - metrics: [], - trend: [], - columns: [], - rows: [], - }; - } - - const latest = rows[0]; - return { - tabKey: 'navigator', - product, - source: 'JianSun.dbo.pdd_GoodsQualities', - status: 'ready', - metrics: [ - { key: 'avgDescRevScr1m', label: '品质评价得分', value: latest.avgDescRevScr1m }, - { key: 'qltyRfndOrdrCntRto1m', label: '品质退款率', value: latest.qltyRfndOrdrCntRto1m, unit: '%' }, - { key: 'ptHelpPzRate30d', label: '品质平台求助率', value: latest.ptHelpPzRate30d, unit: '%' }, - { key: 'midRto30d', label: '品质文本情感分', value: latest.midRto30d }, - ], - trend: [...rows].reverse().map((row) => ({ - date: row.statDate, - primary: row.avgDescRevScr1m, - secondary: row.qltyRfndOrdrCntRto1m, - })), - trendLabels: { primary: '品质评价得分', secondary: '品质退款率' }, - columns: [ - { key: 'statDate', title: '数据日期' }, - { key: 'avgDescRevScr1m', title: '品质评价得分' }, - { key: 'qltyRfndOrdrCntRto1m', title: '品质退款率' }, - { key: 'ptHelpPzRate30d', title: '品质平台求助率' }, - { key: 'midRto30d', title: '品质文本情感分' }, - { key: 'goodsScoreRkStage', title: '综合得分表现' }, - ], - rows, - }; -} - -async function buildServiceIssuesPayload(product, statDate) { - const rows = await jianSunDb.query( - `SELECT - id, - COALESCE(NULLIF(LTRIM(RTRIM(title)), N''), N'其他问题') AS issueType, - NULLIF(LTRIM(RTRIM(orderSn)), '') AS orderSn, - CASE WHEN isNeg = 1 THEN 1 ELSE 0 END AS isNegative, - CONVERT(varchar(19), TRY_CONVERT(datetime2, seekHelpTime), 120) AS seekHelpTime, - NULLIF(LTRIM(RTRIM(probDetail)), N'') AS problemDetail, - NULLIF(LTRIM(RTRIM(clfy2Desc)), N'') AS categoryDescription - FROM dbo.pdd_ServiceProblemOrders WITH (NOLOCK) - WHERE goodsId = TRY_CONVERT(bigint, @goodsId) - AND createTime = @statDate - ORDER BY TRY_CONVERT(datetime2, seekHelpTime) DESC, id DESC`, - { - goodsId: product.goodsId, - statDate, - } - ); - - const detailMap = new Map(); - rows.forEach((row) => { - const orderSn = String(row.orderSn || '').trim(); - if (!orderSn) return; - const issueType = String(row.issueType || '其他问题').trim() || '其他问题'; - const key = `${issueType}\u0000${orderSn}`; - const current = detailMap.get(key); - if (current) { - current.isNegative = current.isNegative || Number(row.isNegative) === 1; - if (!current.problemDetail && row.problemDetail) current.problemDetail = row.problemDetail; - if (!current.categoryDescription && row.categoryDescription) { - current.categoryDescription = row.categoryDescription; - } - return; - } - detailMap.set(key, { - id: Number(row.id) || key, - issueType, - orderSn, - isNegative: Number(row.isNegative) === 1, - seekHelpTime: row.seekHelpTime || '', - problemDetail: row.problemDetail || '', - categoryDescription: row.categoryDescription || '', - }); - }); - const orderDetails = [...detailMap.values()]; - - if (!orderDetails.length) { - return { - tabKey: 'service-issues', - product, - source: 'JianSun.dbo.pdd_ServiceProblemOrders', - status: 'empty', - message: `当前商品在 ${statDate} 暂无体验问题数据`, - metrics: [], - trend: [], - columns: [], - rows: [], - orderDetails: [], - }; - } - - const issueGroups = new Map(); - const allOrders = new Map(); - orderDetails.forEach((row) => { - if (!issueGroups.has(row.issueType)) issueGroups.set(row.issueType, []); - issueGroups.get(row.issueType).push(row); - const existing = allOrders.get(row.orderSn); - allOrders.set(row.orderSn, Boolean(existing || row.isNegative)); - }); - const latestSummary = [...issueGroups.entries()] - .map(([issueType, issueRows]) => ({ - id: `${statDate}:${issueType}`, - statDate, - issueType, - issueOrderCount: issueRows.length, - negativeOrderCount: issueRows.filter((row) => row.isNegative).length, - latestSeekHelpTime: issueRows[0]?.seekHelpTime || '', - viewOrders: '查看订单', - })) - .sort((left, right) => ( - right.issueOrderCount - left.issueOrderCount - || left.issueType.localeCompare(right.issueType, 'zh-CN') - )); - const productIssueCount = latestSummary - .filter((row) => row.issueType === '商品问题') - .reduce((sum, row) => sum + row.issueOrderCount, 0); - const customerServiceIssueCount = latestSummary - .filter((row) => row.issueType.includes('客服')) - .reduce((sum, row) => sum + row.issueOrderCount, 0); - - return { - tabKey: 'service-issues', - product, - source: 'JianSun.dbo.pdd_ServiceProblemOrders', - status: 'ready', - latestDate: statDate, - metrics: [ - { key: 'issueOrderCount', label: '问题订单', value: allOrders.size }, - { key: 'productIssueCount', label: '商品问题', value: productIssueCount }, - { key: 'customerServiceIssueCount', label: '客服问题', value: customerServiceIssueCount }, - { key: 'negativeOrderCount', label: '负向体验', value: [...allOrders.values()].filter(Boolean).length }, - ], - trend: [], - tableTitle: `问题汇总 · ${statDate}`, - columns: [ - { key: 'issueType', title: '问题类型', align: 'left' }, - { key: 'issueOrderCount', title: '问题订单数', align: 'right' }, - { key: 'negativeOrderCount', title: '负向体验订单', align: 'right' }, - { key: 'latestSeekHelpTime', title: '最近求助时间', align: 'left' }, - { key: 'viewOrders', title: '操作', align: 'left' }, - ], - rows: latestSummary, - orderDetails, - }; -} - -router.get('/signal-thresholds', async (req, res) => { - try { - const userId = getRequestUserId(req); - if (!userId) { - return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); - } - - res.json({ - success: true, - data: await loadUserProductSignalThresholds(userId), - }); - } catch (error) { - res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -router.put('/signal-thresholds', async (req, res) => { - try { - const userId = getRequestUserId(req); - if (!userId) { - return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); - } - - const thresholds = normalizeProductSignalThresholds(req.body?.thresholds ?? req.body); - const validationError = validateProductSignalThresholds(thresholds); - if (validationError) { - return res.status(400).json({ success: false, error: validationError }); - } - - await persistProductSignalThresholds(userId, thresholds); - res.json({ success: true, data: { configured: true, thresholds } }); - } catch (error) { - res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -router.get('/meta', async (req, res) => { - try { - const scope = await getDataScope(req.user.sub); - if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); - - res.json({ success: true, data: await loadProductMeta() }); - } catch (error) { - res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -router.get('/dashboard', async (req, res) => { - let settleDashboard = null; - let activeCacheKey = ''; - try { - const requestedStartDate = normalizeDate(req.query.startDate); - const requestedEndDate = normalizeDate(req.query.endDate); - const hasCustomDate = Boolean(requestedStartDate || requestedEndDate); - if (hasCustomDate && (!requestedStartDate || !requestedEndDate)) { - return res.status(400).json({ success: false, error: '自定义日期需要同时选择开始日期和结束日期' }); - } - const customDays = hasCustomDate ? inclusiveDayCount(requestedStartDate, requestedEndDate) : 0; - if (hasCustomDate && customDays < 1) { - return res.status(400).json({ success: false, error: '开始日期不能晚于结束日期' }); - } - if (hasCustomDate && customDays > 90) { - return res.status(400).json({ success: false, error: '自定义日期范围不能超过90天' }); - } - const requestedPeriod = hasCustomDate - ? 'custom' - : DASHBOARD_PERIOD_DAYS[req.query.period] ? req.query.period : 'week'; - const shopName = String(req.query.shopName || '').trim(); - const cacheKey = JSON.stringify({ - period: requestedPeriod, - startDate: requestedStartDate, - endDate: requestedEndDate, - shopName, - }); - activeCacheKey = cacheKey; - const cached = dashboardCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return res.json({ success: true, data: cached.data }); - } - const pending = dashboardInFlight.get(cacheKey); - if (pending) { - const result = await pending; - if (result.error) throw result.error; - return res.json({ success: true, data: result.data }); - } - - dashboardInFlight.set(cacheKey, new Promise((resolve) => { - settleDashboard = resolve; - })); - - const period = requestedPeriod; - const days = hasCustomDate ? customDays : DASHBOARD_PERIOD_DAYS[period]; - const [salesResult, serviceResult, starResult] = await Promise.all([ - runDashboardQuery( - 'sales', - `WITH source_bounds AS ( - SELECT TOP (1) LEFT(createTime, 10) AS maxDate - FROM dbo.pdd_DataGoods - WHERE createTime >= '2000-01-01' - AND createTime < '2100-01-01' - AND (@shopName = '' OR shopName = @shopName) - ORDER BY createTime DESC - ), - daily_coverage AS ( - SELECT LEFT(g.createTime, 10) AS statDate, COUNT(DISTINCT g.shopName) AS shopCount - FROM dbo.pdd_DataGoods g - CROSS JOIN source_bounds b - WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, -45, TRY_CONVERT(date, b.maxDate)), 23) - AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, b.maxDate)), 23) - AND g.shopName IS NOT NULL - AND LTRIM(RTRIM(g.shopName)) <> '' - AND (@shopName = '' OR g.shopName = @shopName) - GROUP BY LEFT(g.createTime, 10) - ), - coverage_target AS ( - SELECT MAX(shopCount) AS maxShopCount FROM daily_coverage - ), - available_end AS ( - SELECT MAX(c.statDate) AS latestDate - FROM daily_coverage c - CROSS JOIN coverage_target t - WHERE c.shopCount >= t.maxShopCount * 0.8 - ), - selected_end AS ( - SELECT CASE - WHEN @requestedEndDate <> '' AND @requestedEndDate < latestDate THEN @requestedEndDate - ELSE latestDate - END AS latestDate - FROM available_end - ) - SELECT - LEFT(g.createTime, 10) AS statDate, - e.latestDate AS latestDate, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrAmt), 0)) AS payAmount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrUsrCnt), 0)) AS buyerCount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsUv), 0)) AS visitorCount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsPv), 0)) AS goodsPv, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrGoodsQty), 0)) AS payGoodsQty, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrCnt), 0)) AS payOrderCount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsFavCnt), 0)) AS favoriteCount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.imprUsrCnt), 0)) AS impressionCount - FROM dbo.pdd_DataGoods g - CROSS JOIN selected_end e - WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, 1 - @historyDays, TRY_CONVERT(date, e.latestDate)), 23) - AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, e.latestDate)), 23) - AND (@shopName = '' OR g.shopName = @shopName) - GROUP BY LEFT(g.createTime, 10), e.latestDate - ORDER BY LEFT(g.createTime, 10)`, - { historyDays: days * 2, requestedEndDate: requestedEndDate || '', shopName } - ), - runDashboardQuery( - 'service-score', - `WITH ranked AS ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber - FROM dbo.pdd_MallServiceScore - WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' - AND createTime LIKE '[12][0-9][0-9][0-9]-%' - AND (@shopName = '' OR shopName = @shopName) - ) - SELECT - MAX(LEFT(createTime, 10)) AS createTime, - COUNT(*) AS shopCount, - AVG(TRY_CONVERT(decimal(18, 6), cstmrServScore)) AS cstmrServScore, - AVG(TRY_CONVERT(decimal(18, 6), spLmScore)) AS spLmScore, - AVG(TRY_CONVERT(decimal(18, 6), fhLmScore)) AS fhLmScore, - AVG(TRY_CONVERT(decimal(18, 6), wlLmScore)) AS wlLmScore - FROM ranked - WHERE rowNumber = 1`, - { shopName } - ), - runDashboardQuery( - 'shop-stars', - `WITH ranked AS ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber - FROM dbo.pdd_ShopStars - WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' - AND createTime LIKE '[12][0-9][0-9][0-9]-%' - AND (@shopName = '' OR shopName = @shopName) - ) - SELECT - MAX(LEFT(createTime, 10)) AS createTime, - COUNT(*) AS shopCount, - AVG(TRY_CONVERT(decimal(18, 6), mallStarTomms)) AS mallStarTomms, - AVG(TRY_CONVERT(decimal(18, 6), gmvRk)) AS gmvRk, - AVG(TRY_CONVERT(decimal(18, 6), scoreRk)) AS scoreRk - FROM ranked - WHERE rowNumber = 1`, - { shopName } - ), - ]); - const salesRows = salesResult.rows; - const serviceRows = serviceResult.rows; - const starRows = starResult.rows; - const warnings = [salesResult, serviceResult, starResult] - .filter((result) => result.error) - .map((result) => result.name); - const normalizedSales = salesRows - .map((row) => ({ - date: String(row.statDate || ''), - payAmount: toNumber(row.payAmount), - buyerCount: toNumber(row.buyerCount), - visitorCount: toNumber(row.visitorCount), - goodsPv: toNumber(row.goodsPv), - payGoodsQty: toNumber(row.payGoodsQty), - payOrderCount: toNumber(row.payOrderCount), - favoriteCount: toNumber(row.favoriteCount), - impressionCount: toNumber(row.impressionCount), - })) - .filter((row) => normalizeDate(row.date)); - const latestDate = String(salesRows[0]?.latestDate || normalizedSales.at(-1)?.date || ''); - const currentStart = hasCustomDate ? requestedStartDate : latestDate ? addDays(latestDate, 1 - days) : ''; - const previousEnd = currentStart ? addDays(currentStart, -1) : ''; - const previousStart = previousEnd ? addDays(previousEnd, 1 - days) : ''; - const currentRows = normalizedSales.filter((row) => row.date >= currentStart && row.date <= latestDate); - const previousRows = normalizedSales.filter((row) => row.date >= previousStart && row.date <= previousEnd); - const current = sumRows(currentRows); - const previous = sumRows(previousRows); - const metrics = latestDate && !salesResult.error - ? buildDashboardMetrics(current, previous) - : buildEmptyDashboardMetrics(); - - const spend = null; - const gmv = null; - const roi = null; - - const responseData = { - platform: 'pdd', - source: 'jiansun_database', - scope: shopName ? 'single-shop' : 'all-shops', - shopName: shopName || '全部店铺', - warnings, - period, - days, - latestDate, - startDate: currentStart, - previousStart, - previousEnd, - metrics, - trend: currentRows, - promotion: { - spend, - gmv, - roi, - clickCount: null, - orderCount: null, - }, - refunds: { - amount: null, - rate: null, - products: [], - }, - store: { - serviceDate: toDateString(serviceRows[0]?.createTime), - starDate: toDateString(starRows[0]?.createTime), - shopCount: Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount)), - star: starRows[0]?.mallStarTomms == null ? null : toNumber(starRows[0].mallStarTomms), - peerDescription: shopName - ? shopName - : `已汇总 ${Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount))} 家店铺`, - activityRank: starRows[0]?.gmvRk == null ? null : toNumber(starRows[0].gmvRk), - scoreRank: starRows[0]?.scoreRk == null ? null : toNumber(starRows[0].scoreRk), - scores: [ - { key: 'experience', label: '服务体验', value: serviceRows[0]?.cstmrServScore == null ? null : toNumber(serviceRows[0].cstmrServScore) }, - { key: 'product', label: '商品服务', value: serviceRows[0]?.spLmScore == null ? null : toNumber(serviceRows[0].spLmScore) }, - { key: 'logistics', label: '物流服务', value: serviceRows[0]?.wlLmScore == null ? null : toNumber(serviceRows[0].wlLmScore) }, - { key: 'shipping', label: '发货服务', value: serviceRows[0]?.fhLmScore == null ? null : toNumber(serviceRows[0].fhLmScore) }, - ], - }, - }; - dashboardCache.set(cacheKey, { data: responseData, expiresAt: Date.now() + DASHBOARD_CACHE_TTL_MS }); - settleDashboard({ data: responseData }); - dashboardInFlight.delete(cacheKey); - res.json({ success: true, data: responseData }); - } catch (error) { - settleDashboard?.({ error }); - if (activeCacheKey) dashboardInFlight.delete(activeCacheKey); - res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -router.get('/products', async (req, res) => { - try { - const userId = getRequestUserId(req); - if (!userId) { - return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); - } - const scope = await getDataScope(userId); - if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); - - const page = positiveInt(req.query.page, 1, 1000000); - const pageSize = positiveInt(req.query.pageSize, 20, 100); - const requestedStartDate = req.query.startDate || req.query.statDate; - const requestedEndDate = req.query.endDate || req.query.statDate; - const range = await resolveDateRange(requestedStartDate, requestedEndDate); - const rangeDays = positiveInt(req.query.rangeDays, 1, 90); - if (!normalizeDate(requestedStartDate) && !normalizeDate(requestedEndDate) && rangeDays > 1) { - range.startDate = addDays(range.endDate, -(rangeDays - 1)); - } - const shopName = String(req.query.shopName || '').trim(); - const ider = String(req.query.ider || '').trim(); - const keyword = String(req.query.keyword || '').trim(); - const listingFrom = normalizeDate(req.query.listingFrom); - const listingTo = normalizeDate(req.query.listingTo); - const onlyActive = ['1', 'true', 'yes'].includes(String(req.query.onlyActive || '').toLowerCase()); - const requestedSignalStatus = String(req.query.signalStatus || '').trim().toLowerCase(); - const signalStatus = PRODUCT_SIGNAL_STATUSES.has(requestedSignalStatus) ? requestedSignalStatus : ''; - const sortField = PRODUCT_SORT_FIELDS[req.query.sortField] || 'payOrdrAmt'; - const sortOrder = String(req.query.sortOrder || '').toLowerCase() === 'ascend' ? 'ASC' : 'DESC'; - - const [sourceRows, mappingRows] = await Promise.all([ - loadProductSourceRows(range.startDate, range.endDate, shopName), - loadProductMapping(), - ]); - - const mapping = mappingRows; - const normalizedKeyword = keyword.toLowerCase(); - let rows = sourceRows.map((sourceRow) => { - const row = asNumericRow(sourceRow); - const extra = mapping.get(String(row.goodsId)) || {}; - const goodsUv = toNumber(row.goodsUv); - const buyers = toNumber(row.payOrdrUsrCnt); - const quantity = toNumber(row.payOrdrGoodsQty); - const amount = toNumber(row.payOrdrAmt); - return { - ...row, - ...extra, - statDate: range.endDate, - startDate: range.startDate, - endDate: range.endDate, - goodsVcr: goodsUv > 0 ? (buyers * 100) / goodsUv : 0, - customerUnitPrice: buyers > 0 ? amount / buyers : 0, - pieceUnitPrice: quantity > 0 ? amount / quantity : 0, - }; - }); - - if (!scope.isAllData) { - const allowed = new Set(scope.departments); - rows = rows.filter((row) => row.ownerDept && allowed.has(row.ownerDept)); - } - if (ider) { - rows = rows.filter((row) => String(row.idOwner || '').trim() === ider); - } - if (listingFrom || listingTo) { - rows = rows.filter((row) => { - if (!row.firstCreatedAt) return false; - if (listingFrom && row.firstCreatedAt < listingFrom) return false; - if (listingTo && row.firstCreatedAt > listingTo) return false; - return true; - }); - } - if (normalizedKeyword) { - rows = rows.filter((row) => [row.goodsName, row.goodsId, row.skuCode, row.spu] - .some((value) => String(value || '').toLowerCase().includes(normalizedKeyword))); - } - if (onlyActive) { - rows = rows.filter((row) => toNumber(row.payOrdrAmt) > 0 || toNumber(row.spend) > 0); - } - if (signalStatus && rows.length) { - const [signalSnapshot, userSignalPrefs] = await Promise.all([ - loadProductSignalSnapshot(range.endDate), - loadUserProductSignalThresholds(userId), - ]); - rows = rows - .map((row) => ({ - ...row, - ...(signalSnapshot.get(String(row.goodsId)) || {}), - })) - .filter((row) => resolveProductSignalStatus(row, userSignalPrefs.thresholds) === signalStatus); - } - - const direction = sortOrder === 'ASC' ? 1 : -1; - rows.sort((left, right) => { - const difference = toNumber(left[sortField]) - toNumber(right[sortField]); - return difference === 0 - ? String(left.goodsId).localeCompare(String(right.goodsId)) - : difference * direction; - }); - - const summary = rows.reduce((result, row) => { - result.payOrdrAmt += toNumber(row.payOrdrAmt); - result.orderNum += toNumber(row.orderNum); - result.payOrdrUsrCnt += toNumber(row.payOrdrUsrCnt); - result.payOrdrGoodsQty += toNumber(row.payOrdrGoodsQty); - result.goodsUv += toNumber(row.goodsUv); - result.spend += toNumber(row.spend); - if (toNumber(row.payOrdrAmt) > 0) result.activeGoods += 1; - return result; - }, { - total: rows.length, - activeGoods: 0, - payOrdrAmt: 0, - orderNum: 0, - payOrdrUsrCnt: 0, - payOrdrGoodsQty: 0, - goodsUv: 0, - spend: 0, - startDate: range.startDate, - endDate: range.endDate, - }); - summary.goodsVcr = summary.goodsUv > 0 - ? (summary.payOrdrUsrCnt * 100) / summary.goodsUv - : 0; - - const pagedRows = rows.slice((page - 1) * pageSize, page * pageSize); - let responseRows = pagedRows; - if (!signalStatus) { - let productSignals = new Map(); - try { - productSignals = await loadProductSignals(pagedRows.map((row) => row.goodsId), range.endDate); - } catch (error) { - console.warn('[product-analysis/products] traffic signal query failed:', error.message); - } - responseRows = pagedRows.map((row) => ({ - ...row, - ...(productSignals.get(String(row.goodsId)) || {}), - })); - } - res.json({ - success: true, - data: { - page, - pageSize, - total: Number(summary.total || 0), - statDate: range.endDate, - startDate: range.startDate, - endDate: range.endDate, - signalStatus: signalStatus || 'all', - metrics: summary, - rows: responseRows, - }, - }); - } catch (error) { - res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -router.get('/products/:goodsId/tabs/:tabKey', async (req, res) => { - try { - const { goodsId, tabKey } = req.params; - if (!TAB_KEYS.has(tabKey)) { - return res.status(404).json({ success: false, error: 'unknown product analysis tab' }); - } - - const scope = await getDataScope(req.user.sub); - if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); - const range = await resolveDateRange( - req.query.startDate || req.query.statDate, - req.query.endDate || req.query.statDate - ); - - const scopeWhere = []; - const scopeParams = []; - appendScope(scopeWhere, scopeParams, scope); - const scopeSql = scopeWhere.length ? ` AND ${scopeWhere.join(' AND ')}` : ''; - const [sourceProductRows, productRows] = await Promise.all([ - middleDb.query( - `SELECT TOP (1) - CONVERT(varchar(50), goodsId) AS goodsId, - goodsName, - hdThumbUrl, - shopName, - cate1Name, - cate2Name, - cate3Name - FROM dbo.pdd_DataGoods - WHERE TRY_CONVERT(bigint, goodsId) = @goodsId - AND TRY_CONVERT(date, createTime) <= @endDate - ORDER BY TRY_CONVERT(date, createTime) DESC, createdAt DESC`, - { goodsId, endDate: range.endDate } - ), - planningDb.query( - `SELECT - DATE_FORMAT(statDate, '%Y-%m-%d') AS statDate, - DATE_FORMAT(startDate, '%Y-%m-%d') AS startDate, - DATE_FORMAT(endDate, '%Y-%m-%d') AS endDate, - spu, skuCode, goodsId, goodsName, hdThumbUrl, idOwner, ownerDept, shopName, - goodsUv, ctr, clickConversionRate, goodsVcr, ordrVstrRto, payOrdrRto, - imprUsrCnt, unpaidUserCount, payOrdrAmt, orderNum, payOrdrUsrCnt, - payOrdrGoodsQty, customerUnitPrice, pieceUnitPrice, spend, feeRatio, - afterSaleRate, afterSaleCount, badReviewCount, isPriceBreak - FROM ${PRODUCT_SNAPSHOT_TABLE} - WHERE goodsId = ? - AND statDate <= ?${scopeSql} - ORDER BY statDate DESC - LIMIT 1`, - [goodsId, range.endDate, ...scopeParams] - ), - ]); - const product = { - ...(sourceProductRows[0] || {}), - ...(productRows[0] || {}), - goodsId, - startDate: range.startDate, - endDate: range.endDate, - }; - if (!scope.isAllData && !productRows[0]) { - return res.status(403).json({ success: false, error: 'product access denied' }); - } - if (!sourceProductRows[0] && !productRows[0]) { - return res.status(404).json({ success: false, error: 'product not found' }); - } - - if (tabKey === 'activities') { - return res.json({ success: true, data: await buildActivityPayload(product, range.startDate, range.endDate) }); - } - if (tabKey === 'navigator') { - return res.json({ success: true, data: await buildQualityPayload(product, range.endDate) }); - } - if (tabKey === 'service-issues') { - return res.json({ - success: true, - data: await buildServiceIssuesPayload(product, range.endDate), - }); - } - - const history = await planningDb.query( - `SELECT - DATE_FORMAT(statDate, '%Y-%m-%d') AS statDate, - goodsUv, ctr, clickConversionRate, goodsVcr, imprUsrCnt, unpaidUserCount, - payOrdrAmt, orderNum, payOrdrUsrCnt, payOrdrGoodsQty, spend, feeRatio, - afterSaleRate, afterSaleCount, badReviewCount - FROM ${PRODUCT_SNAPSHOT_TABLE} - WHERE goodsId = ? - AND statDate >= ? - AND statDate <= ?${scopeSql} - ORDER BY statDate DESC - LIMIT 60`, - [goodsId, range.startDate, range.endDate, ...scopeParams] - ); - - res.json({ success: true, data: buildTabPayload(tabKey, product, history) }); - } catch (error) { - res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -module.exports = router; diff --git a/backend/src/api/productMonitoring.js b/backend/src/api/productMonitoring.js deleted file mode 100644 index ba703d1..0000000 --- a/backend/src/api/productMonitoring.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -const express = require('express'); -const { - getMonitoringList, - getMonitoringFilters, - getDepartmentTagConfig, - saveDepartmentTagConfig, - getProductOperators, -} = require('../services/productMonitoringService'); - -const router = express.Router(); - -function sendError(res, error, fallback = '商品监控请求失败') { - const status = Number(error?.statusCode) || 500; - console.error('[ProductMonitoring]', { - message: error?.message || fallback, - code: error?.code || null, - status, - stack: error?.stack || null, - }); - res.status(status).json({ success: false, error: error.message || fallback }); -} - -// 明细列表(含公司标签 / 部门标签 / 负责人 / 汇总) -router.get('/list', async (req, res) => { - try { - res.json({ success: true, data: await getMonitoringList(req.query || {}) }); - } catch (error) { - sendError(res, error, '加载商品监控列表失败'); - } -}); - -// 筛选下拉选项 -router.get('/filters', async (_req, res) => { - try { - res.json({ success: true, data: await getMonitoringFilters() }); - } catch (error) { - sendError(res, error, '加载筛选选项失败'); - } -}); - -// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道) -router.get('/operators', async (req, res) => { - try { - const sku = req.query?.sku || req.query?.skuoid; - if (!sku) { - return res.status(400).json({ success: false, error: '缺少 sku 参数' }); - } - return res.json({ success: true, data: { operators: await getProductOperators(sku) } }); - } catch (error) { - return sendError(res, error, '加载运营明细失败'); - } -}); - -// 部门标签 + 负责人配置(系统设置用) -router.get('/department-config', async (_req, res) => { - try { - res.json({ success: true, data: await getDepartmentTagConfig() }); - } catch (error) { - sendError(res, error, '加载部门标签配置失败'); - } -}); - -router.put('/department-config', async (req, res) => { - try { - res.json({ success: true, data: await saveDepartmentTagConfig(req.body || {}) }); - } catch (error) { - sendError(res, error, '保存部门标签配置失败'); - } -}); - -module.exports = router; diff --git a/backend/src/api/promotionModule.js b/backend/src/api/promotionModule.js deleted file mode 100644 index 2a1e62d..0000000 --- a/backend/src/api/promotionModule.js +++ /dev/null @@ -1,417 +0,0 @@ -const express = require('express'); -const jianSunDb = require('../config/jianSunDatabase'); -const middleDb = require('../config/middlePlatformDatabase'); - -const router = express.Router(); - -const MAX_PAGE_SIZE = 100; - -function safeInt(value, fallback) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function toNum(value) { - const n = Number(value); - return Number.isFinite(n) ? n : 0; -} - -function fmtMoney(value) { - const n = Number(value); - return Number.isFinite(n) ? n.toFixed(2) : '-'; -} - -function fmtRate(value) { - const n = Number(value); - return Number.isFinite(n) ? (n * 100).toFixed(2) : '-'; -} - -function fmtNum(value) { - const n = Number(value); - return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-'; -} - -function getDateRange(period, customStart, customEnd) { - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - switch (period) { - case 'realtime': { - const d = new Date(today); - return { start: formatDate(d), end: formatDate(d) }; - } - case 'yesterday': { - const d = new Date(today); - d.setDate(d.getDate() - 1); - return { start: formatDate(d), end: formatDate(d) }; - } - case 'week': { - const d = new Date(today); - d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1)); - return { start: formatDate(d), end: formatDate(today) }; - } - case 'month': { - const d = new Date(today.getFullYear(), today.getMonth(), 1); - return { start: formatDate(d), end: formatDate(today) }; - } - case 'last7d': { - const d = new Date(today); - d.setDate(d.getDate() - 6); - return { start: formatDate(d), end: formatDate(today) }; - } - case 'last30d': { - const d = new Date(today); - d.setDate(d.getDate() - 29); - return { start: formatDate(d), end: formatDate(today) }; - } - case 'custom': { - return { - start: customStart || formatDate(today), - end: customEnd || formatDate(today), - }; - } - default: { - const d = new Date(today); - d.setDate(d.getDate() - 6); - return { start: formatDate(d), end: formatDate(today) }; - } - } -} - -function formatDate(d) { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; -} - -// 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移) -function getPreviousRange(range) { - const start = new Date(`${range.start}T00:00:00`); - const end = new Date(`${range.end}T00:00:00`); - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - return null; - } - const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1); - const prevEnd = new Date(start); - prevEnd.setDate(prevEnd.getDate() - 1); - const prevStart = new Date(prevEnd); - prevStart.setDate(prevStart.getDate() - (lengthDays - 1)); - return { start: formatDate(prevStart), end: formatDate(prevEnd) }; -} - -const PROMOTION_SUMMARY_SELECT = `SELECT - COUNT(*) AS totalRecords, - SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend, - SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv, - CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0 - THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) - ELSE 0 END AS avgRoi, - SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend, - SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv, - CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0 - THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) - ELSE 0 END AS avgNetRoi, - SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum, - CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0 - THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum])) - ELSE 0 END AS avgCostPerNetOrder - FROM dbo.pdd_ReportGoodsSpread - WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`; - -async function queryPromotionSummary(range) { - const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, { - startDate: range.start, - endDate: range.end, - }); - const stats = row || {}; - return { - totalRecords: toNum(stats.totalRecords), - orderSpend: toNum(stats.totalOrderSpend), - gmv: toNum(stats.totalGmv), - roi: toNum(stats.avgRoi), - spend: toNum(stats.totalSpend), - netGmv: toNum(stats.totalNetGmv), - netRoi: toNum(stats.avgNetRoi), - netOrderNum: toNum(stats.totalNetOrderNum), - costPerNetOrder: toNum(stats.avgCostPerNetOrder), - }; -} - -// 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null -function buildPeriodChanges(current, previous) { - const changes = {}; - Object.keys(current).forEach((key) => { - const base = Math.abs(previous[key]); - changes[key] = base > 0 ? (current[key] - previous[key]) / base : null; - }); - return changes; -} - -async function getImageMap(goodsIds) { - if (!goodsIds.length) return new Map(); - try { - const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(','); - const params = {}; - goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); }); - const rows = await middleDb.query( - `SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl - FROM dbo.pdd_DataGoods - WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`, - params - ); - const map = new Map(); - rows.forEach((row) => { - if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl); - }); - return map; - } catch { - return new Map(); - } -} - -router.get('/latest-date', async (req, res) => { - try { - const rows = await jianSunDb.query( - `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate - FROM dbo.pdd_ReportGoodsSpread - WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' - ORDER BY createDate DESC` - ); - const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || ''); - res.json({ success: true, data: { date: String(date || '').trim() } }); - } catch (error) { - console.error('[PromotionModule] 查询最新数据日期失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/shops', async (req, res) => { - try { - const rows = await jianSunDb.query( - `SELECT DISTINCT [shopName] - FROM dbo.pdd_ReportGoodsSpread - WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != '' - ORDER BY [shopName]` - ); - res.json({ success: true, data: rows.map((r) => r.shopName) }); - } catch (error) { - console.error('[PromotionModule] 查询店铺列表失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/summary', async (req, res) => { - try { - const { date, period, start: customStart, end: customEnd } = req.query; - let filterDate = (date && String(date).trim()) || ''; - let range; - - if (period === 'custom') { - range = getDateRange('custom', customStart, customEnd); - } else if (period) { - range = getDateRange(period); - filterDate = ''; - } - - if (!range) { - // 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期) - if (!filterDate) { - const dateRows = await jianSunDb.query( - `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate - FROM dbo.pdd_ReportGoodsSpread - WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' - ORDER BY createDate DESC` - ); - filterDate = dateRows.length >= 2 - ? String(dateRows[1].createDate || '').trim() - : String(dateRows[0]?.createDate || '').trim(); - } - range = { start: filterDate, end: filterDate }; - } - - const previousRange = getPreviousRange(range); - const [current, previous] = await Promise.all([ - queryPromotionSummary(range), - previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null), - ]); - - res.json({ - success: true, - data: { - filterDate: `${range.start} ~ ${range.end}`, - previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '', - totalRecords: current.totalRecords, - orderSpend: fmtMoney(current.orderSpend), - gmv: fmtMoney(current.gmv), - roi: fmtNum(current.roi), - spend: fmtMoney(current.spend), - netGmv: fmtMoney(current.netGmv), - netRoi: fmtNum(current.netRoi), - netOrderNum: fmtNum(current.netOrderNum), - costPerNetOrder: fmtMoney(current.costPerNetOrder), - changes: previous ? buildPeriodChanges(current, previous) : null, - }, - }); - } catch (error) { - console.error('[PromotionModule] 查询汇总数据失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/list', async (req, res) => { - try { - const { - page = 1, - pageSize = 20, - sortBy = 'orderSpend', - sortOrder = 'DESC', - keyword, - shopName, - date, - period, - start: customStart, - end: customEnd, - } = req.query; - - const currentPage = safeInt(page, 1); - const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE); - const offset = (currentPage - 1) * limit; - - const whereParts = []; - const params = {}; - - if (period && period !== 'custom') { - const range = getDateRange(period); - whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate'); - params.startDate = range.start; - params.endDate = range.end; - } else if (period === 'custom' && customStart && customEnd) { - whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate'); - params.startDate = customStart; - params.endDate = customEnd; - } else { - let filterDate = (date && String(date).trim()) || ''; - if (!filterDate) { - const dateRows = await jianSunDb.query( - `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate - FROM dbo.pdd_ReportGoodsSpread - WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' - ORDER BY createDate DESC` - ); - filterDate = dateRows.length >= 2 - ? String(dateRows[1].createDate || '').trim() - : String(dateRows[0]?.createDate || '').trim(); - } - if (filterDate) { - whereParts.push('LEFT([createTime], 10) = @filterDate'); - params.filterDate = filterDate; - } - } - - if (keyword && keyword.trim()) { - whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)'); - params.kw = `%${keyword.trim()}%`; - } - if (shopName && shopName.trim()) { - whereParts.push('[shopName] = @shopName'); - params.shopName = shopName.trim(); - } - - const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; - - const allowedSort = new Set([ - 'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi', - 'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount', - 'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr', - ]); - const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]'; - const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; - - const countResult = await jianSunDb.query( - `SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`, - params - ); - const total = countResult[0]?.total || 0; - - const dataResult = await jianSunDb.query( - `SELECT - [id], - [goodsName], - [goodsId], - [shopName], - [createTime], - [orderSpend], - [gmv], - [roi], - [spend], - [orderNum], - [transactionCost], - [avgPayAmount], - [directGmv], - [indirectGmv], - [directOrderNum], - [indirectOrderNum], - [netGmv], - [netRoi], - [netOrderNum], - [netAvgPayAmount], - [netRate], - [settlementGmv], - [settlementRoi], - [settlementOrder], - [refundGmvRate30d], - [refundOrderRate30d], - [exemptRefundGmvRate30d], - [exemptRefundOrderRate30d], - [settlementGmvRate], - [settlementOrderRate], - [costPerSettlementOrderForDeal], - [impression], - [click], - [ctr], - [cvr], - [targetRoi], - [targetBid] - FROM dbo.pdd_ReportGoodsSpread - ${whereSql} - ORDER BY ${sortCol} ${sortDir} - OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`, - { ...params, offset, limit } - ); - - const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean); - const imageMap = await getImageMap(goodsIds); - - const list = dataResult.map((row) => ({ - ...row, - thumbUrl: imageMap.get(String(row.goodsId)) || '', - orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null, - gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null, - spend: row.spend != null ? toNum(row.spend) / 1000.0 : null, - transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null, - avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null, - directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null, - indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null, - netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null, - netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null, - settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null, - costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null, - })); - - res.json({ - success: true, - data: { - list, - total, - page: currentPage, - pageSize: limit, - }, - }); - } catch (error) { - console.error('[PromotionModule] 查询推广数据失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -module.exports = router; diff --git a/backend/src/constants/sidebarPages.js b/backend/src/constants/sidebarPages.js index 0546a50..ed5eee4 100644 --- a/backend/src/constants/sidebarPages.js +++ b/backend/src/constants/sidebarPages.js @@ -14,21 +14,12 @@ const buildPlanningPage = ({ pageKey, pageName, path, allowSubPaths = false }) = }); const SIDEBAR_PAGES = [ - buildPlanningPage({ pageKey: 'ops_b_incentive', pageName: '激励看板', path: '/planning/operation/ops-b-incentive' }), buildPlanningPage({ pageKey: 'dashboard_preview', pageName: '仪表盘(开发预览)', path: '/planning/dev-style-preview', }), - buildPlanningPage({ - pageKey: 'marketing_events', - pageName: '营销活动', - path: '/planning/operation/marketing-events', - allowSubPaths: true, - }), - buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }), buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }), - buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }), buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }), { pageKey: 'settings', diff --git a/backend/src/index.js b/backend/src/index.js index 5349b9a..a285953 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -29,10 +29,7 @@ const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin'); const channelRoutes = require('./api/channel'); const analysisRoutes = require('./api/analysis'); const productCodeBoardRoutes = require('./api/productCodeBoard'); -const productAnalysisRoutes = require('./api/productAnalysis'); -const marketingEventsRoutes = require('./api/marketingEvents'); -const promotionModuleRoutes = require('./api/promotionModule'); -const productMonitoringRoutes = require('./api/productMonitoring'); +const dashboardRoutes = require('./api/dashboard'); const database = require('./config/database'); const cloudDb = require('./config/cloudDatabase'); const jianSunDb = require('./config/jianSunDatabase'); @@ -43,8 +40,6 @@ const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductE const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob'); const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob'); const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob'); -const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob'); -const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); const { startAlibaba1688ImageSearchExpiryScheduler, } = require('./jobs/alibaba1688ImageSearchExpiryJob'); @@ -128,7 +123,6 @@ function shouldHandlePlanningUploadPageCache(req) { req.path === '/' || req.path.startsWith('/table/may_product_data') || req.path.startsWith('/analysis/may-channel-insight') - || req.path.startsWith('/analysis/ops-b-incentive') || req.path.startsWith('/selection-fission') ) { return false; @@ -646,12 +640,9 @@ async function startServer(options = {}) { cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes); cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes); cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes); - cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); - cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); - cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); + cubejsApp.use('/api/dashboard', authMiddleware, dashboardRoutes); cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); - cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); // ===================== // 数据分析 API(公开访问,可根据需要添加认证) @@ -717,8 +708,6 @@ async function startServer(options = {}) { startPddSkuDimensionSnapshotSchedulers(); startPlanningMayProductSnapshotSchedulers(); startPlanningDingWorkflowSyncSchedulers(); - startOpsBIncentiveSnapshotSchedulers(); - startGoodsFirstSeenScheduler(); startAlibaba1688ImageSearchExpiryScheduler(); // Keep the first selection-pool page request from paying the remote schema diff --git a/backend/src/jobs/goodsFirstSeenJob.js b/backend/src/jobs/goodsFirstSeenJob.js deleted file mode 100644 index 3daf32d..0000000 --- a/backend/src/jobs/goodsFirstSeenJob.js +++ /dev/null @@ -1,164 +0,0 @@ -const cron = require('node-cron'); -const jianSunDb = require('../config/jianSunDatabase'); -const planningDb = require('../services/planningDatabase'); -const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService'); - -const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim(); -const TIMEZONE = 'Asia/Shanghai'; -const SYNC_CHUNK_SIZE = 500; -const BACKFILL_CHUNK_SIZE = 800; -const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily'; - -let schemaEnsured = false; - -async function ensureTable() { - if (schemaEnsured) return; - await planningDb.query(` - CREATE TABLE IF NOT EXISTS pdd_goods_first_seen ( - goods_id VARCHAR(50) NOT NULL PRIMARY KEY, - first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)', - last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期', - is_onsale TINYINT NOT NULL DEFAULT 0, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); - schemaEnsured = true; -} - -function epochToDatetime(value) { - const num = Number(value); - if (!Number.isFinite(num) || num <= 0) return null; - const ms = num >= 1e12 ? num : num * 1000; - const date = new Date(ms); - return Number.isNaN(date.getTime()) ? null : date; -} - -// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at) -async function syncCaptureDate(captureDate) { - await ensureTable(); - const startedAt = Date.now(); - const rows = await jianSunDb.query( - `SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId, - MIN(s.createdAt) AS minCreatedAt, - MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale - FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) - WHERE s.createTime = @captureDate - AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> '' - GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`, - { captureDate }, - { requestTimeout: 300000 } - ); - - const records = []; - rows.forEach((row) => { - const createdAt = epochToDatetime(row.minCreatedAt); - if (!createdAt) return; - records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]); - }); - - const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'); - for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) { - const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE); - await planningDb.query( - `INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale) - VALUES ? - ON DUPLICATE KEY UPDATE - last_capture_date = VALUES(last_capture_date), - is_onsale = VALUES(is_onsale)`, - [chunk] - ); - } - const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'); - - return { - captureDate, - goodsInBatch: records.length, - upserted: records.length, - newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)), - total: Number(countAfter[0]?.n || 0), - elapsedMs: Date.now() - startedAt, - }; -} - -// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验) -async function refreshLatestCapture() { - const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10)); - if (!captureDate) { - console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步'); - return { skipped: true }; - } - return syncCaptureDate(captureDate); -} - -// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制) -async function backfillAllHistory() { - await ensureTable(); - const startedAt = Date.now(); - const goodsRows = await planningDb.query( - `SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})` - ); - const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))]; - console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`); - - const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0); - let synced = 0; - for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) { - const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE); - const params = {}; - const placeholders = chunk.map((goodsId, index) => { - params[`g${index}`] = Number(goodsId); - return `@g${index}`; - }).join(', '); - const rows = await jianSunDb.query( - `SELECT s.goodsId AS goodsId, - MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt, - MAX(s.createTime) AS lastCaptureRaw, - MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale - FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus)) - WHERE s.goodsId IN (${placeholders}) - GROUP BY s.goodsId`, - params, - { requestTimeout: 300000 } - ); - - const records = []; - rows.forEach((row) => { - const createdAt = epochToDatetime(row.minCreatedAt); - const lastCapture = epochToDatetime(row.lastCaptureRaw); - if (!createdAt) return; - records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]); - }); - for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) { - const chunk = records.slice(i, i + SYNC_CHUNK_SIZE); - await planningDb.query( - `INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale) - VALUES ? - ON DUPLICATE KEY UPDATE - last_capture_date = VALUES(last_capture_date), - is_onsale = VALUES(is_onsale)`, - [chunk] - ); - } - synced += records.length; - console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`); - } - const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0); - const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt }; - console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`); - return summary; -} - -function startGoodsFirstSeenScheduler() { - const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true'; - if (disabled) { - console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用'); - return; - } - cron.schedule(JOB_CRON, () => { - refreshLatestCapture().catch((error) => { - console.error('[GoodsFirstSeen] 定时同步失败:', error.message); - }); - }, { timezone: TIMEZONE }); - console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`); -} - -module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler }; diff --git a/backend/src/jobs/opsBIncentiveSnapshotJob.js b/backend/src/jobs/opsBIncentiveSnapshotJob.js deleted file mode 100644 index ece8b09..0000000 --- a/backend/src/jobs/opsBIncentiveSnapshotJob.js +++ /dev/null @@ -1,31 +0,0 @@ -const cron = require('node-cron'); -const { refreshOpsBIncentiveSnapshots } = require('../services/opsBIncentiveBoardService'); - -const JOB_CRON = String(process.env.OPS_B_INCENTIVE_SNAPSHOT_CRON || '0 * * * *').trim(); -const TIMEZONE = 'Asia/Shanghai'; - -async function runOpsBIncentiveSnapshotJob(source = 'scheduled') { - try { - const result = await refreshOpsBIncentiveSnapshots(source); - console.log('[OpsBIncentiveSnapshot] refresh completed:', JSON.stringify(result)); - return result; - } catch (error) { - console.error('[OpsBIncentiveSnapshot] refresh failed:', error.message || error); - return []; - } -} - -function startOpsBIncentiveSnapshotSchedulers() { - // 默认开启;仅当显式设置 DISABLE_OPS_B_INCENTIVE_SNAPSHOT=true 时关闭。 - const disabled = String(process.env.DISABLE_OPS_B_INCENTIVE_SNAPSHOT || '').trim().toLowerCase() === 'true'; - if (disabled) { - console.log('[Scheduler] 运营中心B激励看板快照任务已被禁用'); - return; - } - cron.schedule(JOB_CRON, () => { - runOpsBIncentiveSnapshotJob('daily_schedule').catch(() => {}); - }, { timezone: TIMEZONE }); - console.log(`[Scheduler] 运营中心B激励看板快照任务已注册: ${JOB_CRON} (${TIMEZONE})`); -} - -module.exports = { startOpsBIncentiveSnapshotSchedulers, runOpsBIncentiveSnapshotJob }; diff --git a/backend/src/services/desktopSessionService.js b/backend/src/services/desktopSessionService.js index 6ce964c..a6eec71 100644 --- a/backend/src/services/desktopSessionService.js +++ b/backend/src/services/desktopSessionService.js @@ -77,8 +77,6 @@ function buildDesktopCapabilities(user) { shopManagement: canPlan, execution: canPlan, logs: true, - marketingEvents: isAdmin || allowed.has('marketing_events'), - promotion: isAdmin || allowed.has('promotion_module'), productTest: isAdmin || allowed.has('new_product_test'), }; } diff --git a/backend/src/services/localSchema.js b/backend/src/services/localSchema.js index 3e3d5fa..c813ddc 100644 --- a/backend/src/services/localSchema.js +++ b/backend/src/services/localSchema.js @@ -7,11 +7,6 @@ const { ensureDesktopPresenceSchema } = require('./desktopPresenceService'); const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService'); const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService'); const { ensureThirdPartySsoDirectSchema } = require('./thirdPartySsoDirectService'); -const { ensureProductTagConfigSchema } = require('./productTagConfigService'); -const { - ensureProductMonitoringSchema, - warmProductMonitoringDataset, -} = require('./productMonitoringService'); const USER_COLUMNS = [ ['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"], @@ -118,18 +113,6 @@ async function ensureTable(tableName, createSql) { } } -async function tableExists(tableName) { - const rows = await database.local.query( - `SELECT 1 - FROM information_schema.tables - WHERE table_schema = DATABASE() - AND table_name = ? - LIMIT 1`, - [tableName] - ); - return rows.length > 0; -} - async function indexExists(tableName, indexName) { const rows = await database.local.query( `SELECT 1 @@ -255,67 +238,10 @@ async function seedRedisConfigFromEnv() { } async function ensureLocalSchema() { - await ensureTable( - 'ops_b_incentive_snapshots', - `CREATE TABLE IF NOT EXISTS ops_b_incentive_snapshots ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - snapshot_key VARCHAR(80) NOT NULL, - scope_start DATE NOT NULL, - scope_end DATE NOT NULL, - metric_end DATE NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'completed', - finalized TINYINT(1) NOT NULL DEFAULT 0, - rows_json MEDIUMTEXT NOT NULL, - summary_json TEXT NULL, - generated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - completed_at DATETIME NULL, - error_message TEXT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_ops_b_snapshot_key (snapshot_key), - KEY idx_ops_b_snapshot_period (scope_start, scope_end, metric_end, status) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` - ); - await ensureTable( - 'ops_b_incentive_periods', - `CREATE TABLE IF NOT EXISTS ops_b_incentive_periods ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - scope_start DATE NOT NULL COMMENT '上架日期区间起', - scope_end DATE NOT NULL COMMENT '上架日期区间止', - pull_date DATE NOT NULL COMMENT '拉取(冻结)日期', - fee_cap DECIMAL(5,4) NOT NULL COMMENT '费比达标上限', - status VARCHAR(20) NOT NULL DEFAULT 'active', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` - ); - await ensureTable( - 'product_analysis_user_prefs', - `CREATE TABLE IF NOT EXISTS product_analysis_user_prefs ( - user_id BIGINT PRIMARY KEY COMMENT '用户ID', - prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化配置 JSON', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置'` - ); - if (await tableExists('pricing_calculator_user_prefs')) { - await database.local.query( - `INSERT INTO product_analysis_user_prefs (user_id, prefs_json, created_at, updated_at) - SELECT user_id, prefs_json, created_at, updated_at - FROM pricing_calculator_user_prefs - WHERE JSON_VALID(prefs_json) - AND JSON_EXTRACT(prefs_json, '$.productAnalysisSignalThresholds') IS NOT NULL - ON DUPLICATE KEY UPDATE - prefs_json = VALUES(prefs_json), - updated_at = VALUES(updated_at)` - ); - } await ensureDesktopPresenceSchema(); await ensureDesktopExecutionTaskSchema(); await ensureThirdPartySsoSchema(); await ensureThirdPartySsoDirectSchema(); - await ensureProductTagConfigSchema(); await ensureTable( 'auth_configs', @@ -951,11 +877,7 @@ async function ensureLocalSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫控价快照读库'` ); - await ensureProductMonitoringSchema(); - await ensureSidebarRolePermissions(); - // 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动 - void warmProductMonitoringDataset(); await ensureDepartmentPagePermissions(); await cleanupRetiredFeatureSettings(database.local); await repairDuplicateExternalDepartments(); diff --git a/backend/src/services/opsBIncentiveBoardService.js b/backend/src/services/opsBIncentiveBoardService.js deleted file mode 100644 index 5374412..0000000 --- a/backend/src/services/opsBIncentiveBoardService.js +++ /dev/null @@ -1,430 +0,0 @@ -const dayjs = require('dayjs'); -const database = require('../config/database'); -const jianSunDb = require('../config/jianSunDatabase'); -const cloudDatabase = require('../config/cloudDatabase'); - -const DEFAULT_SCOPE_START = '2026-07-15'; -const DEFAULT_SCOPE_END = '2026-07-21'; -const DEFAULT_END_DATE = '2026-09-02'; -const AWARD_PER_ID = 400; -const JULY_START = '2026-07-01'; -const JULY_END = '2026-07-31'; - -function text(value, fallback = '') { - const valueText = String(value ?? '').trim(); - return valueText || fallback; -} - -function validDate(value, fallback) { - const valueText = text(value, fallback); - return /^\d{4}-\d{2}-\d{2}$/u.test(valueText) && dayjs(valueText).isValid() ? valueText : fallback; -} - -function addDays(value, offset) { - return dayjs(value).add(offset, 'day').format('YYYY-MM-DD'); -} - -function resolveQuery(query = {}) { - const scopeStart = validDate(query.scopeStart || query.startDate, DEFAULT_SCOPE_START); - const scopeEnd = validDate(query.scopeEnd || query.endDate, DEFAULT_SCOPE_END); - const endDate = validDate(query.metricEnd || query.asOfDate, DEFAULT_END_DATE); - const metricStart = addDays(endDate, -29); - const feeCap = scopeStart >= '2026-07-22' ? 0.88 : 0.9; - return { - scopeStart, - scopeEnd: scopeEnd < scopeStart ? scopeStart : scopeEnd, - metricStart, - metricEnd: endDate, - feeCap, - ider: text(query.ider), - shopName: text(query.shopName), - spu: text(query.spu), - goodsId: text(query.goodsId), - }; -} - -function normalizeNumber(value) { - if (value === null || value === undefined || value === '') return null; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function normalizeRow(row = {}, query) { - const shippedDays30d = Number(row.shippedDays30d || 0); - const performance30d = normalizeNumber(row.performance30d); - const fee30d = normalizeNumber(row.fee30d); - const feeRatio30d = performance30d > 0 && fee30d !== null ? fee30d / performance30d : null; - const firstCreatedAt = row.firstCreatedAt ? new Date(row.firstCreatedAt) : null; - const listingStart = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime()) - ? dayjs(firstCreatedAt).startOf('day') - : null; - const metricEndDay = dayjs(query.metricEnd).startOf('day'); - const listedCalendarDays = listingStart && metricEndDay.isValid() - ? Math.max(1, metricEndDay.diff(listingStart, 'day') + 1) - : 30; - const metricDenominatorDays = Math.min(30, listedCalendarDays); - const sellThroughRate30d = shippedDays30d / metricDenominatorDays; - const feeQualified = performance30d > 0 && feeRatio30d !== null && feeRatio30d <= query.feeCap; - const bonusQualified = feeQualified && sellThroughRate30d > 0.3; - const listingDays = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime()) - ? Math.max(0, dayjs(query.metricEnd).diff(dayjs(firstCreatedAt), 'day')) - : null; - return { - ...row, - goodsId: text(row.goodsId), - ider: text(row.ider), - shopName: text(row.shopName), - spu: text(row.spu), - goodsName: text(row.goodsName), - linkType: text(row.linkType), - status: Number(row.onsale || 0) === 1 ? '在售' : '已下架', - skuCount: Number(row.skuCount || 0), - listingDays, - julyPerformance: normalizeNumber(row.julyPerformance), - julyFee: normalizeNumber(row.julyFee), - julyFeeRatio: normalizeNumber(row.julyPerformance) > 0 ? normalizeNumber(row.julyFee) / normalizeNumber(row.julyPerformance) : null, - julyGoodsCount: normalizeNumber(row.julyGoodsCount) || 0, - julyShippedDays: Number(row.julyShippedDays || 0), - julySellThroughRate: Number(row.julyShippedDays || 0) / 31, - performance30d, - fee30d, - feeRatio30d, - goodsCount30d: normalizeNumber(row.goodsCount30d) || 0, - shippedDays30d, - metricDenominatorDays, - sellThroughRate30d, - feeQualified, - sellThroughQualified: sellThroughRate30d > 0.3, - bonusQualified, - bonus: bonusQualified ? AWARD_PER_ID : 0, - }; -} - -function buildParams(query) { - const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000); - return { - scopeStart: query.scopeStart, - scopeEnd: query.scopeEnd, - metricStart: query.metricStart, - metricEnd: query.metricEnd, - julyStart: JULY_START, - julyEnd: JULY_END, - ider: query.ider, - shopName: query.shopName, - spu: query.spu, - goodsId: query.goodsId, - scopeStartEpochSec: epoch(query.scopeStart), - scopeEndEpochSec: epoch(addDays(query.scopeEnd, 1)), - scopeStartEpochMs: epoch(query.scopeStart) * 1000, - scopeEndEpochMs: epoch(addDays(query.scopeEnd, 1)) * 1000, - }; -} - -function emptyMetrics() { - return { julyPerformance: 0, julyFee: 0, julyGoodsCount: 0, julyShippedDays: 0, performance30d: 0, fee30d: 0, goodsCount30d: 0, shippedDays30d: 0 }; -} - -async function loadSalesMetrics(goodsIds, query) { - const metrics = new Map(); - const ids = [...new Set((goodsIds || []).map((value) => String(value || '').trim()).filter((value) => /^\d+$/u.test(value)))]; - for (let offset = 0; offset < ids.length; offset += 1000) { - const chunk = ids.slice(offset, offset + 1000); - const inList = chunk.map((id) => `'${id}'`).join(','); - const rows = await cloudDatabase.query(` - SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) AS goodsId, - SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS julyPerformance, - SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS julyFee, - SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS julyGoodsCount, - COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS julyShippedDays, - SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS performance30d, - SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS fee30d, - SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS goodsCount30d, - COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS shippedDays30d - FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK) - WHERE d.item_id IN (${inList}) - AND d.stat_date BETWEEN @julyStart AND @metricEnd - AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多') - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0 - GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) - `, { julyStart: JULY_START, julyEnd: JULY_END, metricStart: query.metricStart, metricEnd: query.metricEnd }, { requestTimeout: 180000 }); - rows.forEach((row) => metrics.set(String(row.goodsId), row)); - } - return metrics; -} - -// pdd_Skus 由外部采集系统写入,存在残缺批次(如 2026-09-02 仅 113 个商品)。 -// 从 upperBound 往回 7 天内挑最新一个商品数不低于窗口最大值 70% 的批次,避免用到不完整采集。 -async function pickSkuCaptureTime(upperBound) { - const end = dayjs(upperBound).isAfter(dayjs(), 'day') ? dayjs().format('YYYY-MM-DD') : dayjs(upperBound).format('YYYY-MM-DD'); - const dates = Array.from({ length: 7 }, (_, index) => dayjs(end).subtract(index, 'day').format('YYYY-MM-DD')); - const counts = await Promise.all(dates.map((captureTime) => jianSunDb.query( - 'SELECT COUNT(DISTINCT goodsId) AS goodsCount FROM dbo.pdd_Skus WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE createTime = @captureTime', - { captureTime }, { requestTimeout: 60000 } - ))); - const mapped = dates.map((dateValue, index) => ({ date: dateValue, goodsCount: Number(counts[index]?.[0]?.goodsCount || 0) })); - const maxCount = Math.max(...mapped.map((item) => item.goodsCount)); - if (maxCount <= 0) return null; - return (mapped.find((item) => item.goodsCount >= maxCount * 0.7) || mapped.find((item) => item.goodsCount === maxCount)).date; -} - -async function getOpsBIncentiveBoardLive(input = {}) { - const query = resolveQuery(input); - const livePeriod = findPeriod(await loadPeriods(), query); - if (livePeriod) query.feeCap = livePeriod.feeCap; - const targetRows = await jianSunDb.query(`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid))) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.ider)))) AS ider, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), tm.shopname)))) AS shopName, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.spu)))) AS spu, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.linkType)))) AS linkType FROM dbo.op_TargetManagement tm WITH (NOLOCK) WHERE tm.dept = N'运营中心B' GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid)))`, {}, { requestTimeout: 60000 }); - const latestCreateTime = await pickSkuCaptureTime(query.metricEnd); - if (!latestCreateTime) throw new Error('pdd_Skus 近7天无可用完整采集批次'); - const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000); - const startSec = epoch(query.scopeStart); - const endSec = epoch(addDays(query.scopeEnd, 1)); - const skuRows = await jianSunDb.query(`SELECT CONVERT(NVARCHAR(100), s.goodsId) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.goodsName)))) AS goodsName, MIN(DATEADD(SECOND, CONVERT(INT, CASE WHEN s.createdAt >= 1000000000000 THEN s.createdAt / 1000 ELSE s.createdAt END), CONVERT(DATETIME2, '1970-01-01'))) AS firstCreatedAt, MAX(CASE WHEN TRY_CONVERT(INT, s.isOnsale) = 1 THEN 1 ELSE 0 END) AS onsale, COUNT_BIG(1) AS skuCount FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE s.createTime = @latestCreateTime AND ((s.createdAt >= @startSec AND s.createdAt < @endSec) OR (s.createdAt >= @startMs AND s.createdAt < @endMs)) GROUP BY s.goodsId`, { latestCreateTime, startSec, endSec, startMs: startSec * 1000, endMs: endSec * 1000 }, { requestTimeout: 180000 }); - const targetMap = new Map(targetRows.map((row) => [String(row.goodsId), row])); - const scopeRows = skuRows.flatMap((row) => { - const target = targetMap.get(String(row.goodsId)); - if (!target) return []; - if ((query.ider && target.ider !== query.ider) || (query.shopName && target.shopName !== query.shopName) || (query.spu && !String(target.spu || '').includes(query.spu)) || (query.goodsId && !String(row.goodsId).includes(query.goodsId))) return []; - return [{ ...row, ...target }]; - }); - const salesMetrics = await loadSalesMetrics(scopeRows.map((row) => row.goodsId), query); - const rows = scopeRows.map((row) => ({ ...emptyMetrics(), ...row, ...(salesMetrics.get(String(row.goodsId)) || {}) })); - let dwsLatestRows = []; - try { - dwsLatestRows = await cloudDatabase.query(`SELECT MAX(TRY_CONVERT(DATE, stat_date)) AS latestDate FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] WITH (NOLOCK) WHERE ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), platform))), N'') IN (N'Pinduoduo', N'拼多多')`, {}, { requestTimeout: 60000 }); - } catch (error) { - console.warn('[OpsBIncentiveBoard] latest DWS date unavailable:', error.message); - } - const normalizedRows = (rows || []).map((row) => normalizeRow(row, query)); - const qualifiedRows = normalizedRows.filter((row) => row.bonusQualified); - const ownerMap = new Map(); - normalizedRows.forEach((row) => { - const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 }; - current.goodsIdCount += 1; - current.goodsCount30d += row.goodsCount30d; - current.shippedDays30d += row.shippedDays30d; - current.feeQualifiedCount += row.feeQualified ? 1 : 0; - current.bonusQualifiedCount += row.bonusQualified ? 1 : 0; - current.bonus += row.bonus; - ownerMap.set(row.ider, current); - }); - return { - rows: normalizedRows, - bonusRows: qualifiedRows, - ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount), - summary: { - goodsIdCount: normalizedRows.length, - feeQualifiedCount: normalizedRows.filter((row) => row.feeQualified).length, - sellThroughQualifiedCount: normalizedRows.filter((row) => row.sellThroughQualified).length, - bonusQualifiedCount: qualifiedRows.length, - bonus: qualifiedRows.length * AWARD_PER_ID, - awardPerId: AWARD_PER_ID, - }, - query: { ...query, julyStart: JULY_START, julyEnd: JULY_END, latestDwsDate: dwsLatestRows?.[0]?.latestDate || null }, - }; -} - -// 口径来源:运营中心B激励方案批次表。pullDate = 拉取(冻结)日期,统计窗口固定为 pullDate 往前 30 天。 -// 批次配置存 MySQL ops_b_incentive_periods 表(可动态新增批次,无需发版),此列表仅作首次种子与兜底。 -const DEFAULT_SNAPSHOT_PERIODS = [ - { scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 }, - { scopeStart: '2026-07-22', scopeEnd: '2026-07-28', pullDate: '2026-09-05', feeCap: 0.88 }, - { scopeStart: '2026-07-29', scopeEnd: '2026-08-04', pullDate: '2026-09-12', feeCap: 0.875 }, - { scopeStart: '2026-08-05', scopeEnd: '2026-08-11', pullDate: '2026-09-19', feeCap: 0.87 }, - { scopeStart: '2026-08-12', scopeEnd: '2026-08-18', pullDate: '2026-09-26', feeCap: 0.865 }, - { scopeStart: '2026-08-19', scopeEnd: '2026-08-25', pullDate: '2026-10-03', feeCap: 0.86 }, - { scopeStart: '2026-08-26', scopeEnd: '2026-09-01', pullDate: '2026-10-10', feeCap: 0.855 }, - { scopeStart: '2026-09-02', scopeEnd: '2026-09-08', pullDate: '2026-10-17', feeCap: 0.85 }, - { scopeStart: '2026-09-09', scopeEnd: '2026-09-15', pullDate: '2026-10-24', feeCap: 0.845 }, - { scopeStart: '2026-09-16', scopeEnd: '2026-09-22', pullDate: '2026-10-31', feeCap: 0.84 }, -]; - -function makePeriodKey(scopeStart, scopeEnd) { - return `ops-b-${scopeStart.replace(/-/g, '')}-${scopeEnd.replace(/-/g, '')}`; -} - -let periodsCache = null; -let periodsCacheExpiresAt = 0; - -async function loadPeriods() { - if (periodsCache && Date.now() < periodsCacheExpiresAt) return periodsCache; - let rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end"); - if (!rows.length) { - for (const period of DEFAULT_SNAPSHOT_PERIODS) { - await database.local.query( - 'INSERT IGNORE INTO ops_b_incentive_periods (scope_start, scope_end, pull_date, fee_cap, status) VALUES (?, ?, ?, ?, ?)', - [period.scopeStart, period.scopeEnd, period.pullDate, period.feeCap, 'active'] - ); - } - rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end"); - } - periodsCache = rows - .filter((row) => String(row.status || 'active') === 'active') - .map((row) => ({ - key: makePeriodKey(dayjs(row.scope_start).format('YYYY-MM-DD'), dayjs(row.scope_end).format('YYYY-MM-DD')), - scopeStart: dayjs(row.scope_start).format('YYYY-MM-DD'), - scopeEnd: dayjs(row.scope_end).format('YYYY-MM-DD'), - pullDate: dayjs(row.pull_date).format('YYYY-MM-DD'), - feeCap: Number(row.fee_cap), - })); - periodsCacheExpiresAt = Date.now() + 60000; - return periodsCache; -} - -function findPeriod(periods, query) { - return periods.find((period) => period.scopeStart === query.scopeStart && period.scopeEnd === query.scopeEnd) || null; -} - -async function readSnapshot(period) { - const rows = await database.local.query( - 'SELECT rows_json, summary_json, metric_end, finalized, generated_at FROM ops_b_incentive_snapshots WHERE snapshot_key = ? AND status = ? LIMIT 1', - [period.key, 'completed'] - ); - const row = rows[0]; - if (!row) return null; - try { - const meta = JSON.parse(row.summary_json || '{}'); - return { rows: JSON.parse(row.rows_json || '[]'), summary: meta.summary || {}, ownerSummary: meta.ownerSummary || [], metricEnd: row.metric_end, finalized: Boolean(row.finalized), generatedAt: row.generated_at }; - } catch (error) { - console.warn('[OpsBIncentiveSnapshot] invalid snapshot:', error.message); - return null; - } -} - -async function writeSnapshot(period, query, result, finalized) { - await database.local.query(` - INSERT INTO ops_b_incentive_snapshots - (snapshot_key, scope_start, scope_end, metric_end, status, finalized, rows_json, summary_json, generated_at, completed_at, error_message) - VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, NOW(), NOW(), NULL) - ON DUPLICATE KEY UPDATE - metric_end = VALUES(metric_end), finalized = VALUES(finalized), rows_json = VALUES(rows_json), summary_json = VALUES(summary_json), - generated_at = VALUES(generated_at), completed_at = VALUES(completed_at), status = 'completed', error_message = NULL - `, [period.key, query.scopeStart, query.scopeEnd, query.metricEnd, finalized ? 1 : 0, JSON.stringify(result.rows || []), JSON.stringify({ summary: result.summary || {}, ownerSummary: result.ownerSummary || [] })]); -} - -async function getOpsBIncentiveBoard(input = {}) { - const query = resolveQuery(input); - const period = findPeriod(await loadPeriods(), query); - if (period) query.feeCap = period.feeCap; - if (!period) return getOpsBIncentiveBoardLive(input); - const existing = await readSnapshot(period); - if (!existing) { - return { - pending: true, - rows: [], - bonusRows: [], - ownerSummary: [], - summary: {}, - query: { ...query, latestDwsDate: null, snapshot: { cached: false, finalized: false, pending: true } }, - }; - } - const filteredRows = existing.rows.filter((row) => ( - (!query.ider || row.ider === query.ider) - && (!query.shopName || row.shopName === query.shopName) - && (!query.spu || String(row.spu || '').includes(query.spu)) - && (!query.goodsId || String(row.goodsId || '').includes(query.goodsId)) - )); - const ownerMap = new Map(); - filteredRows.forEach((row) => { - const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 }; - current.goodsIdCount += 1; - current.goodsCount30d += Number(row.goodsCount30d || 0); - current.shippedDays30d += Number(row.shippedDays30d || 0); - current.feeQualifiedCount += row.feeQualified ? 1 : 0; - current.bonusQualifiedCount += row.bonusQualified ? 1 : 0; - current.bonus += Number(row.bonus || 0); - ownerMap.set(row.ider, current); - }); - const bonusRows = filteredRows.filter((row) => row.bonusQualified); - return { - ...existing, - rows: filteredRows, - bonusRows, - ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount), - summary: { - goodsIdCount: filteredRows.length, - feeQualifiedCount: filteredRows.filter((row) => row.feeQualified).length, - sellThroughQualifiedCount: filteredRows.filter((row) => row.sellThroughQualified).length, - bonusQualifiedCount: bonusRows.length, - bonus: bonusRows.reduce((sum, row) => sum + Number(row.bonus || 0), 0), - awardPerId: AWARD_PER_ID, - }, - query: { - ...query, - metricEnd: dayjs(existing.metricEnd).format('YYYY-MM-DD'), - latestDwsDate: null, - snapshot: { cached: true, finalized: existing.finalized, generatedAt: existing.generatedAt }, - }, - }; -} - -async function refreshOpsBIncentiveSnapshots(source = 'scheduled') { - const today = dayjs().format('YYYY-MM-DD'); - const results = []; - for (const period of await loadPeriods()) { - const { scopeStart, scopeEnd } = period; - // 拉取日次日首刷才定档,确保统计窗口包含拉取日全天数据(数仓滞后约一天)。 - const finalized = today > period.pullDate; - const existing = await readSnapshot(period); - if (existing?.finalized) { - results.push({ key: period.key, rows: existing.rows.length, finalized: true, skipped: true, source }); - continue; - } - const result = await getOpsBIncentiveBoardLive({ scopeStart, scopeEnd, metricEnd: period.pullDate }); - await writeSnapshot(period, { scopeStart, scopeEnd, metricEnd: period.pullDate }, result, finalized); - results.push({ key: period.key, rows: result.rows.length, finalized, source }); - } - return results; -} - -async function getOpsBIncentiveBatches() { - const periods = await loadPeriods(); - const snapshots = await database.local.query( - "SELECT snapshot_key, finalized, generated_at, JSON_EXTRACT(summary_json, '$.summary.goodsIdCount') goodsIdCount, JSON_EXTRACT(summary_json, '$.summary.bonusQualifiedCount') bonusQualifiedCount, JSON_EXTRACT(summary_json, '$.summary.bonus') bonus FROM ops_b_incentive_snapshots WHERE status = 'completed'" - ); - const snapshotMap = new Map(snapshots.map((row) => [row.snapshot_key, row])); - return periods.map((period, index) => { - const snapshot = snapshotMap.get(period.key) || {}; - return { - ...period, - batchNo: index + 1, - finalized: Boolean(snapshot.finalized), - generatedAt: snapshot.generated_at || null, - goodsIdCount: Number(snapshot.goodsIdCount || 0), - bonusQualifiedCount: Number(snapshot.bonusQualifiedCount || 0), - bonus: Number(snapshot.bonus || 0), - }; - }); -} - -async function getOpsBIncentiveDaily(input = {}) { - const query = resolveQuery(input); - const params = buildParams(query); - if (!query.goodsId) return { rows: [], query }; - const rows = await cloudDatabase.query(` - WITH daily AS ( - SELECT TRY_CONVERT(DATE, d.stat_date) AS statDate, - SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.goods_count), 0)) AS goodsCount, - SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_performance), 0)) AS realPerformance, - SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_conclude_fee), 0)) AS realFee - FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK) - WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) = @goodsId - AND TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd - AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多') - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0 - AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0 - GROUP BY TRY_CONVERT(DATE, d.stat_date) - ) SELECT statDate, goodsCount, CASE WHEN goodsCount > 0 THEN N'是' ELSE N'否' END AS isActive, realPerformance, realFee - FROM daily ORDER BY statDate ASC - `, params, { requestTimeout: 120000 }); - return { rows: rows || [], query }; -} - -module.exports = { getOpsBIncentiveBoard, getOpsBIncentiveBoardLive, getOpsBIncentiveDaily, getOpsBIncentiveBatches, refreshOpsBIncentiveSnapshots, resolveQuery, pickSkuCaptureTime, AWARD_PER_ID, DEFAULT_SNAPSHOT_PERIODS }; diff --git a/backend/src/services/productMonitoringService.js b/backend/src/services/productMonitoringService.js deleted file mode 100644 index 8f08983..0000000 --- a/backend/src/services/productMonitoringService.js +++ /dev/null @@ -1,943 +0,0 @@ -'use strict'; - -// 商品监控:全公司在售商品的基础档案 + 库存 + 标签分级 + 部门运营指标 -// -// 数据来源(2026-09-11 实测确认): -// 主表 业务库 rpt.pm_stockMonitor —— 脑图「商品监控」的原始数据源 -// (5.6 万行 / 日更 / 含 sevenAvgSale 日销、jsStock 库存、jsUseDays 可用天数、 -// suggestStock 建议备货、purchase 采购负责人、supplier、warehouse、sku_idStatus) -// 补充 业务库 rpt.jst_sku —— 图片、条码(sku_code)、材质、颜色、尺寸 -// 配置 系统库 product_scale_thresholds / product_profit_levels / product_sabc_mappings -// (公司 SABC,由「系统设置 → 商品标签配置」维护) -// 配置 系统库 product_tag_rules (部门标签 G-S/G-A/G-B/G-C) -// 配置 系统库 department_sku_owners (部门 SKU 负责人) -// -// 判定口径: -// - 公司标签 S/A/B/C/D ← productTagConfigService.resolveProductTags()(业绩 + 日销双命中) -// - 部门标签 G-S/G-A/G-B/G-C ← product_tag_rules(tag_scope='department'),左闭右开,升序命中即停 -// -// ⚠️ 已知留空字段(等业务给源表后再填): -// - 在途库存 inTransitStock → null,前端显示 `--` -// - 部门未发货数量 departmentUnshipped → null,前端显示 `--` -// (pm_stockMonitor.outOfStock 语义未确认,本次不用) - -const database = require('../config/database'); -const cloudDatabase = require('../config/cloudDatabase'); -const productTagConfigService = require('./productTagConfigService'); - -const REPORT_SOURCE_TABLE = 'dbo.pm_stockMonitor'; -// 销售明细(最新,2026-09-09):按 skuoid 聚合可得业绩/销售额、部门、主渠道、主运营 -const SALES_DETAIL_TABLE = 'dbo.dws_sn_sales_full_info_di'; -const SALES_WINDOW_DAYS = 7; -// 公司标签 S/A/B/C 的「业绩」阈值(系统设置 → 商品标签配置)是 30 天量级 -// (如「住宅家具 - 书架」爆款线 48000),因此标签判定用 30 天销售额,日销仍用宽表 sevenAvgSale -const SALES_PERFORMANCE_WINDOW_DAYS = 30; - -const SALE_STATUS_OPTIONS = Object.freeze(['销售', '停产', '退市']); -const DEFAULT_PAGE_SIZE = 50; -const MAX_PAGE_SIZE = 200; - -const DEPARTMENT_TAG_SCOPE = 'department'; -const DEPARTMENT_TAG_LEVELS = Object.freeze(['G-S', 'G-A', 'G-B', 'G-C']); -const DEPARTMENT_METRIC_KEYS = Object.freeze(['sales_qty_7d', 'sales_amount_7d']); - -// ==================== 表结构 ==================== - -async function ensureTable(tableName, createSql) { - await database.local.query(createSql); - const rows = await database.local.query( - `SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = ? - LIMIT 1`, - [tableName] - ); - if (rows.length === 0) { - throw new Error(`Table ${tableName} create failed`); - } -} - -async function ensureProductMonitoringSchema() { - await ensureTable( - 'product_tag_rules', - `CREATE TABLE IF NOT EXISTS product_tag_rules ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department', - tag_level VARCHAR(16) NOT NULL COMMENT '部门标签 G-S/G-A/G-B/G-C', - metric_key VARCHAR(64) NOT NULL COMMENT 'sales_qty_7d | sales_amount_7d', - threshold_min DECIMAL(18,4) NULL, - threshold_max DECIMAL(18,4) NULL, - priority INT NOT NULL DEFAULT 0, - enabled TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key), - KEY idx_scope_enabled (tag_scope, enabled), - KEY idx_metric (metric_key) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'` - ); - - await ensureTable( - 'department_sku_owners', - `CREATE TABLE IF NOT EXISTS department_sku_owners ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - department_id BIGINT UNSIGNED NOT NULL, - owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser', - user_id BIGINT UNSIGNED NOT NULL, - spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id), - KEY idx_dept (department_id), - KEY idx_spu (spu), - KEY idx_user (user_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'` - ); - - // 部门标签默认四档(不存在时播种,口径:近 7 天销售件数) - const existing = await database.local.query( - 'SELECT COUNT(*) AS c FROM product_tag_rules WHERE tag_scope = ?', - [DEPARTMENT_TAG_SCOPE] - ); - if (Number(existing[0]?.c || 0) === 0) { - const seeds = [ - ['G-S', 500, null, 1], - ['G-A', 200, 500, 2], - ['G-B', 50, 200, 3], - ['G-C', 0, 50, 4], - ]; - for (const [level, min, max, priority] of seeds) { - await database.local.query( - `INSERT INTO product_tag_rules - (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) - VALUES (?, ?, 'sales_qty_7d', ?, ?, ?, 1) - ON DUPLICATE KEY UPDATE tag_scope = tag_scope`, - [DEPARTMENT_TAG_SCOPE, level, min, max, priority] - ); - } - } -} - -// ==================== 配置读取 ==================== - -async function loadDepartmentTagRules() { - const rows = await database.local.query( - `SELECT id, tag_level, metric_key, threshold_min, threshold_max, priority, enabled - FROM product_tag_rules - WHERE tag_scope = ? AND enabled = 1 - ORDER BY priority ASC, id ASC`, - [DEPARTMENT_TAG_SCOPE] - ); - return rows.map((row) => ({ - id: row.id, - tagLevel: row.tag_level, - metricKey: row.metric_key, - thresholdMin: row.threshold_min === null ? null : Number(row.threshold_min), - thresholdMax: row.threshold_max === null ? null : Number(row.threshold_max), - priority: Number(row.priority || 0), - })); -} - -// 公司 SKU 负责人:取自 rpt.V_caterAndOper.oper,**SPU 粒度**(方案第 12 条已确认接受该粒度) -async function loadCompanyOwnerIndex() { - try { - const rows = await cloudDatabase.query( - `SELECT spu, oper FROM dbo.V_caterAndOper WHERE oper IS NOT NULL AND oper <> N''` - ); - const index = new Map(); - rows.forEach((row) => { - const spu = String(row.spu || '').trim(); - const oper = String(row.oper || '').trim(); - if (spu && oper && !index.has(spu)) index.set(spu, oper); - }); - return index; - } catch (error) { - console.warn('[ProductMonitoring] 加载公司SKU负责人失败,该列将留空:', error.message); - return new Map(); - } -} - -async function loadDepartmentSkuOwners() { - const rows = await database.local.query( - `SELECT o.department_id, o.owner_type, o.user_id, o.spu, u.name AS user_name, d.name AS department_name - FROM department_sku_owners o - LEFT JOIN users u ON u.id = o.user_id - LEFT JOIN departments d ON d.id = o.department_id` - ); - return rows.map((row) => ({ - departmentId: row.department_id, - departmentName: row.department_name, - ownerType: row.owner_type, - userId: row.user_id, - ownerName: row.user_name, - spu: row.spu, - })); -} - -// 部门标签判定:左闭右开 [min, max),按 priority 升序命中即停;未命中返回 null -function matchDepartmentTag(rules, salesQty7d) { - if (!rules.length) return null; - // ⚠️ 无销售记录(null)不等于「销量 0」:Number(null) 是 0,会把无数据行误判成最低档 - if (salesQty7d === null || salesQty7d === undefined || salesQty7d === '') return null; - const value = Number(salesQty7d); - if (!Number.isFinite(value)) return null; - for (const rule of rules) { - const geMin = rule.thresholdMin === null || value >= rule.thresholdMin; - const ltMax = rule.thresholdMax === null || value < rule.thresholdMax; - if (geMin && ltMax) return rule.tagLevel; - } - return null; -} - -// ==================== 业务库查询 ==================== - -// 可排序字段(排序在内存完成,直接用行字段名) -const DERIVED_SORT_FIELDS = new Set([ - 'skuId', 'spu', 'category', 'productName', 'supplier', 'warehouse', 'purchaser', 'saleStatus', - 'companyOwner', 'companySales7d', 'availableStock', 'stockDays', 'suggestedReplenish', - 'companyTag', 'department', 'departmentTag', 'departmentOwner', - 'performance7d', 'performance30d', 'departmentSales7d', 'mainSalesChannel', 'mainSalesOperator', -]); - -async function getLatestReportDate() { - const rows = await cloudDatabase.query( - `SELECT MAX(date) AS latestDate FROM ${REPORT_SOURCE_TABLE}` - ); - const latest = rows[0]?.latestDate; - if (!latest) { - throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期'); - } - return latest; -} - -// 全量基础行:pm_stockMonitor 主表 + jst_sku 补图片/条码/材质/颜色/尺寸 -// -// ⚠️ 只按统计日过滤、不带任何业务条件——结果会被 buildDataset 缓存, -// 业务条件(状态/品类/SPU/SKU/供应商…)与标签过滤都在内存里做,见 applyBaseFilters。 -async function queryAllReportRows(latestDate) { - const params = { latestDate }; - - const dataSql = ` - SELECT - m.sku_id AS skuId, - m.sku AS skuCode, - m.spu AS spu, - m.skuName AS productName, - m.category AS category, - m.supplier AS supplier, - m.warehouse AS warehouse, - m.purchase AS purchaser, - m.area AS area, - m.cooperation AS cooperation, - m.brand AS brand, - m.sku_idStatus AS saleStatus, - m.jsStock AS availableStock, - m.jsUseDays AS stockDays, - m.sevenAvgSale AS companySales7d, - m.suggestStock AS suggestedReplenish, - m.produceCycle AS produceCycle, - m.safetyDays AS safetyDays, - s.sku_code AS barcode, - s.color AS colorRaw, - s.properties_value AS sizeRaw, - COALESCE(NULLIF(s.other_10, N''), NULLIF(s.other_2, N'')) AS material, - COALESCE(s.pic_big, s.pic) AS imageUrl - FROM ${REPORT_SOURCE_TABLE} m - LEFT JOIN dbo.jst_sku s ON s.sku_id = m.sku_id - WHERE m.date = @latestDate - `; - - const rows = await cloudDatabase.query(dataSql, params, { requestTimeout: 180000 }); - - return { - rows: rows.map((row) => ({ - skuId: row.skuId, - skuCode: row.skuCode || row.skuId, - spu: row.spu, - barcode: row.barcode || row.skuId, - imageUrl: row.imageUrl || null, - category: row.category, - productName: row.productName, - material: row.material || null, - color: row.colorRaw || null, - size: row.sizeRaw || null, - saleStatus: row.saleStatus, - supplier: row.supplier || null, - warehouse: row.warehouse || null, - purchaser: row.purchaser || null, - area: row.area || null, - cooperation: row.cooperation || null, - brand: row.brand || null, - companySales7d: toNumberOrNull(row.companySales7d), - availableStock: toNumberOrNull(row.availableStock), - inTransitStock: null, // 待业务提供源表 - stockDays: toNumberOrNull(row.stockDays), - suggestedReplenish: toNumberOrNull(row.suggestedReplenish), - produceCycle: toNumberOrNull(row.produceCycle), - safetyDays: toNumberOrNull(row.safetyDays), - departmentUnshipped: null, // 待业务确认 outOfStock 语义 - })), - latestDate, - }; -} - -function toNumberOrNull(value) { - if (value === null || value === undefined || value === '') return null; - const num = Number(value); - return Number.isFinite(num) ? num : null; -} - -// ==================== 汇总统计 ==================== - -// 汇总基于「过滤后的最终结果」统计,保证与列表口径一致 -function buildSummary(rows = []) { - let outOfStockCount = 0; - let lowStockDaysCount = 0; - let totalSuggestedReplenish = 0; - let needReplenishCount = 0; - - rows.forEach((row) => { - const stock = row.availableStock === null ? 0 : Number(row.availableStock); - const days = row.stockDays === null ? 0 : Number(row.stockDays); - if (stock <= 0) outOfStockCount += 1; - else if (days < 7) lowStockDaysCount += 1; - - // ⚠️ suggestStock 是有符号的净需求(库存过剩为负数),合计只累计正值,避免负负相加失真 - const replenish = row.suggestedReplenish === null ? 0 : Number(row.suggestedReplenish); - if (replenish > 0) { - totalSuggestedReplenish += replenish; - needReplenishCount += 1; - } - }); - - return { - skuCount: rows.length, - outOfStockCount, - lowStockDaysCount, - totalSuggestedReplenish: Math.round(totalSuggestedReplenish), - needReplenishCount, - }; -} - -// ==================== 销售明细聚合(业绩 / 部门 / 主渠道 / 主运营) ==================== - -// 取销售明细表的最新统计日 -async function getLatestSalesDate() { - const rows = await cloudDatabase.query( - `SELECT MAX(stat_date) AS latestDate FROM ${SALES_DETAIL_TABLE}` - ); - return rows[0]?.latestDate || null; -} - -// 按 skuoid 聚合近 7 天:销售额(业绩口径)、部门、主销售渠道、主销售运营 -async function loadSalesAggregatesBySku() { - try { - const latestDate = await getLatestSalesDate(); - if (!latestDate) return new Map(); - - const rows = await cloudDatabase.query( - `SELECT - d.skuoid AS skuoid, - SUM(d.sales_amount) AS salesAmount30d, - SUM(d.goods_count) AS salesQty30d, - SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.sales_amount ELSE 0 END) AS salesAmount7d, - SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.goods_count ELSE 0 END) AS salesQty7d, - MAX(d.profit_center) AS department, - MAX(d.shop_dept) AS shopDept - FROM ${SALES_DETAIL_TABLE} d - WHERE d.stat_date >= DATEADD(day, -@perfDays, @latestDate) - AND d.skuoid IS NOT NULL AND d.skuoid <> N'' - GROUP BY d.skuoid`, - { days: SALES_WINDOW_DAYS, perfDays: SALES_PERFORMANCE_WINDOW_DAYS, latestDate }, - { requestTimeout: 180000 } - ); - - const index = new Map(); - rows.forEach((row) => { - const key = String(row.skuoid || '').trim(); - if (!key) return; - index.set(key, { - salesAmount7d: toNumberOrNull(row.salesAmount7d), - salesQty7d: toNumberOrNull(row.salesQty7d), - salesAmount30d: toNumberOrNull(row.salesAmount30d), - salesQty30d: toNumberOrNull(row.salesQty30d), - department: row.department || row.shopDept || null, - }); - }); - return index; - } catch (error) { - console.warn('[ProductMonitoring] 加载销售明细聚合失败,业绩/部门列将留空:', error.message); - return new Map(); - } -} - -// 主销售渠道 / 主销售运营:近 7 天销售额最高的渠道与运营(脑图口径) -async function loadMainChannelAndOperator(skuoids) { - const result = new Map(); - if (!Array.isArray(skuoids) || !skuoids.length) return result; - - try { - const latestDate = await getLatestSalesDate(); - if (!latestDate) return result; - - // 仅对本页 SKU 查询,避免全表聚合 - const list = [...new Set(skuoids.filter(Boolean))].slice(0, MAX_PAGE_SIZE); - const params = { days: SALES_WINDOW_DAYS, latestDate }; - const placeholders = list.map((sku, i) => { - params[`sku${i}`] = sku; - return `@sku${i}`; - }); - - const rows = await cloudDatabase.query( - `SELECT d.skuoid AS skuoid, d.shop_name AS shopName, d.ider AS operator, - SUM(d.sales_amount) AS amt - FROM ${SALES_DETAIL_TABLE} d - WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) - AND d.skuoid IN (${placeholders.join(',')}) - GROUP BY d.skuoid, d.shop_name, d.ider`, - params, - { requestTimeout: 120000 } - ); - - const byChannel = new Map(); - const byOperator = new Map(); - rows.forEach((row) => { - const sku = String(row.skuoid || '').trim(); - const amt = toNumberOrNull(row.amt) || 0; - if (!sku) return; - if (row.shopName) { - const key = `${sku}||${row.shopName}`; - const prev = byChannel.get(key) || 0; - byChannel.set(key, prev + amt); - } - if (row.operator) { - const key = `${sku}||${row.operator}`; - const prev = byOperator.get(key) || 0; - byOperator.set(key, prev + amt); - } - }); - - const pickTop = (map, targetMap) => { - map.forEach((amt, key) => { - const [sku, name] = key.split('||'); - const prev = targetMap.get(sku); - if (!prev || amt > prev.amt) targetMap.set(sku, { name, amt }); - }); - }; - const topChannel = new Map(); - const topOperator = new Map(); - pickTop(byChannel, topChannel); - pickTop(byOperator, topOperator); - - list.forEach((sku) => { - result.set(sku, { - mainSalesChannel: topChannel.get(sku)?.name || null, - mainSalesOperator: topOperator.get(sku)?.name || null, - }); - }); - return result; - } catch (error) { - console.warn('[ProductMonitoring] 加载主渠道/主运营失败,相关列将留空:', error.message); - return result; - } -} - -// 行下钻:该 SKU 归属的运营人员列表 + 各自日销(近 7 天日均发货件数) -async function getProductOperators(skuoid) { - const sku = String(skuoid || '').trim(); - if (!sku) return []; - - const latestDate = await getLatestSalesDate(); - if (!latestDate) return []; - - const rows = await cloudDatabase.query( - `SELECT d.ider AS operator, - SUM(d.goods_count) AS qty7d, - SUM(d.sales_amount) AS amt7d, - MAX(d.shop_name) AS sampleShop - FROM ${SALES_DETAIL_TABLE} d - WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) - AND d.skuoid = @sku - AND d.ider IS NOT NULL AND d.ider <> N'' - GROUP BY d.ider - ORDER BY SUM(d.goods_count) DESC`, - { days: SALES_WINDOW_DAYS, latestDate, sku }, - { requestTimeout: 120000 } - ); - - // 每位运营的主渠道 - const channelRows = await cloudDatabase.query( - `SELECT d.ider AS operator, d.shop_name AS shopName, SUM(d.sales_amount) AS amt - FROM ${SALES_DETAIL_TABLE} d - WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) - AND d.skuoid = @sku AND d.ider IS NOT NULL AND d.ider <> N'' - GROUP BY d.ider, d.shop_name`, - { days: SALES_WINDOW_DAYS, latestDate, sku }, - { requestTimeout: 120000 } - ); - - const topChannel = new Map(); - channelRows.forEach((row) => { - const amt = toNumberOrNull(row.amt) || 0; - const prev = topChannel.get(row.operator); - if (!prev || amt > prev.amt) topChannel.set(row.operator, { name: row.shopName, amt }); - }); - - return rows.map((row) => { - const qty7d = toNumberOrNull(row.qty7d) || 0; - return { - operator: row.operator, - dailySales: Number((qty7d / SALES_WINDOW_DAYS).toFixed(2)), - salesQty7d: qty7d, - salesAmount7d: toNumberOrNull(row.amt7d), - mainChannel: topChannel.get(row.operator)?.name || null, - }; - }); -} - -// ==================== 筛选选项 ==================== - -async function queryFilterOptions(latestDate) { - const [dimensionRows, statusRows] = await Promise.all([ - cloudDatabase.query( - `SELECT DISTINCT TOP 300 m.category AS category, m.supplier AS supplier, m.warehouse AS warehouse, - m.purchase AS purchaser - FROM ${REPORT_SOURCE_TABLE} m - WHERE m.date = @latestDate`, - { latestDate } - ), - cloudDatabase.query( - `SELECT m.sku_idStatus AS status, COUNT(*) AS c - FROM ${REPORT_SOURCE_TABLE} m - WHERE m.date = @latestDate - GROUP BY m.sku_idStatus`, - { latestDate } - ), - ]); - - const uniq = (key) => [...new Set(dimensionRows.map((r) => r[key]).filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b), 'zh-CN')); - - return { - categories: uniq('category'), - suppliers: uniq('supplier'), - warehouses: uniq('warehouse'), - purchasers: uniq('purchaser'), - saleStatuses: SALE_STATUS_OPTIONS.map((status) => ({ - status, - count: Number(statusRows.find((r) => r.status === status)?.c || 0), - })), - latestDate, - }; -} - -// ==================== 数据集缓存 ==================== -// -// 实测:mssql 驱动逐行开销约 0.4ms/行,取全量 5.6 万行要 20~30 秒,不能每次请求都跑。 -// 而「公司标签 / 部门标签 / 部门负责人」依赖系统库配置,无法下推到 SQL,必须拿到全量行再算。 -// 因此:按统计日缓存一份「已完成派生」的全量数据集,后续请求只在内存里过滤 + 排序 + 分页。 -// 缓存失效时机:① 统计日变化(日更)② 保存部门标签/负责人配置 ③ 前端带 refresh=1。 - -const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000; -let datasetCache = null; -let datasetBuilding = null; - -const dateKeyOf = (value) => (value ? new Date(value).toISOString().slice(0, 10) : ''); - -async function buildDataset(latestDate) { - const key = dateKeyOf(latestDate); - const [baseResult, departmentRules, departmentOwners, companyTagConfig, companyOwnerIndex, salesAggIndex] = await Promise.all([ - queryAllReportRows(latestDate), - loadDepartmentTagRules(), - loadDepartmentSkuOwners(), - loadCompanyTagResolver(), - loadCompanyOwnerIndex(), - loadSalesAggregatesBySku(), - ]); - - const rows = baseResult.rows.map((row) => { - const salesAgg = salesAggIndex.get(String(row.skuCode || row.skuId).trim()) || null; - - // 业绩口径 = 近 30 天销售额(对齐 SABC 阈值量级),近 7 天销售额单独留一列展示 - const performance30d = salesAgg?.salesAmount30d ?? null; - const performance7d = salesAgg?.salesAmount7d ?? null; - const departmentSalesQty7d = salesAgg?.salesQty7d ?? null; - - const companyTag = resolveCompanyTag(companyTagConfig, row.category, performance30d, row.companySales7d); - const departmentTag = matchDepartmentTag(departmentRules, departmentSalesQty7d); - const department = salesAgg?.department || null; - const owner = resolveDepartmentOwner(departmentOwners, { ...row, department }); - - return { - ...row, - companyOwner: companyOwnerIndex.get(String(row.spu || '').trim()) || null, - department, - // 业绩(销售额)——30 天用于 SABC 判定,7 天用于展示 - performance7d, - performance30d, - // 部门近7日日销(件数 / 7) - departmentSales7d: departmentSalesQty7d === null - ? null - : Number((departmentSalesQty7d / SALES_WINDOW_DAYS).toFixed(2)), - departmentSalesQty7d, - // 建议备货:宽表 suggestStock 为有符号净需求,负数代表库存过剩,展示时取 0 - suggestedReplenish: row.suggestedReplenish === null ? null : Math.max(0, row.suggestedReplenish), - companyTag, - departmentTag, - departmentOwner: owner?.ownerName || null, - mainSalesChannel: null, - mainSalesOperator: null, - }; - }); - - return { key, builtAt: Date.now(), rows }; -} - -async function getDataset(force = false) { - const latestDate = await getLatestReportDate(); - const key = dateKeyOf(latestDate); - - const fresh = datasetCache && datasetCache.key === key - && (Date.now() - datasetCache.builtAt) < DATASET_CACHE_TTL_MS; - if (!force && fresh) return { dataset: datasetCache, latestDate }; - - // 并发去重:同一统计日的构建只跑一次 - if (datasetBuilding && datasetBuilding.key === key) { - return { dataset: await datasetBuilding.promise, latestDate }; - } - - const promise = buildDataset(latestDate) - .then((dataset) => { - datasetCache = dataset; - console.log(`[ProductMonitoring] 数据集构建完成:${key},共 ${dataset.rows.length} 行`); - return dataset; - }) - .finally(() => { datasetBuilding = null; }); - - datasetBuilding = { key, promise }; - return { dataset: await promise, latestDate }; -} - -function invalidateDataset() { - datasetCache = null; -} - -// 启动预热:不阻塞应用启动,构建失败只记日志(首次请求会重试) -async function warmProductMonitoringDataset() { - try { - await getDataset(); - } catch (error) { - console.warn('[ProductMonitoring] 数据集预热失败,将在首次请求时重试:', error.message); - } -} - -// 基础字段过滤(原 SQL WHERE 的等价物,改为内存匹配) -function applyBaseFilters(rows = [], query = {}) { - const like = (value, keyword) => String(value ?? '').toLowerCase() - .includes(String(keyword ?? '').trim().toLowerCase()); - - const keywordFields = [ - ['category', 'category'], - ['spu', 'spu'], - ['sku', 'skuId'], - ['productName', 'productName'], - ['supplier', 'supplier'], - ['warehouse', 'warehouse'], - ['purchaser', 'purchaser'], - ].map(([param, field]) => [field, String(query[param] ?? '').trim()]) - .filter(([, keyword]) => keyword); - - const saleStatus = String(query.saleStatus ?? '').trim(); - const includeAll = String(query.includeAll ?? '') === '1'; - const status = saleStatus || (includeAll ? '' : '销售'); - - if (!keywordFields.length && !status) return rows; - - return rows.filter((row) => { - if (status && String(row.saleStatus || '') !== status) return false; - return keywordFields.every(([field, keyword]) => like(row[field], keyword)); - }); -} - -// ==================== 主入口 ==================== - -async function getMonitoringList(query = {}) { - const forceRefresh = String(query.refresh ?? '') === '1'; - const { dataset, latestDate } = await getDataset(forceRefresh); - - // 1) 基础字段过滤 → 2) 派生字段过滤 → 3) 排序 → 4) 分页 - const filtered = applyDerivedFilters(applyBaseFilters(dataset.rows, query), query); - const sorted = sortRows(filtered, query); - - const total = sorted.length; - const page = Math.max(1, Number(query.page) || 1); - const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE)); - const pageRows = sorted.slice((page - 1) * pageSize, page * pageSize); - - // 主渠道 / 主运营只对本页 SKU 查询,控制代价 - const mainChannelIndex = await loadMainChannelAndOperator(pageRows.map((row) => row.skuCode || row.skuId)); - const rows = pageRows.map((row) => { - const main = mainChannelIndex.get(String(row.skuCode || row.skuId).trim()) || {}; - return { - ...row, - mainSalesChannel: main.mainSalesChannel || null, - mainSalesOperator: main.mainSalesOperator || null, - }; - }); - - return { - rows, - total, - page, - pageSize, - latestDate, - cachedAt: dataset.builtAt, - summary: buildSummary(sorted), - }; -} - -// 派生字段过滤:多选用逗号分隔(companyTag=S,A),文本用包含匹配 -function applyDerivedFilters(rows = [], query = {}) { - const parseList = (value) => String(value ?? '') - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - - const companyTags = parseList(query.companyTag); - const departmentTags = parseList(query.departmentTag); - const departmentKeyword = String(query.department ?? '').trim(); - const departmentOwnerKeyword = String(query.departmentOwner ?? '').trim().toLowerCase(); - const companyOwnerKeyword = String(query.companyOwner ?? '').trim().toLowerCase(); - - if (!companyTags.length && !departmentTags.length && !departmentKeyword - && !departmentOwnerKeyword && !companyOwnerKeyword) { - return rows; - } - - return rows.filter((row) => { - if (companyTags.length && !companyTags.includes(String(row.companyTag || ''))) return false; - if (departmentTags.length && !departmentTags.includes(String(row.departmentTag || ''))) return false; - if (departmentKeyword && String(row.department || '') !== departmentKeyword) return false; - if (departmentOwnerKeyword && !String(row.departmentOwner || '').toLowerCase().includes(departmentOwnerKeyword)) return false; - if (companyOwnerKeyword && !String(row.companyOwner || '').toLowerCase().includes(companyOwnerKeyword)) return false; - return true; - }); -} - -function sortRows(rows = [], query = {}) { - const field = DERIVED_SORT_FIELDS.has(query.sortField) ? query.sortField : 'companySales7d'; - const desc = String(query.sortOrder || '').toUpperCase() !== 'ASC'; - const direction = desc ? -1 : 1; - - return [...rows].sort((a, b) => { - const av = a[field]; - const bv = b[field]; - // 空值恒排末尾,避免 null 参与数值/字符串比较造成的抖动 - if (av === null || av === undefined || av === '') { - return bv === null || bv === undefined || bv === '' ? String(a.skuId).localeCompare(String(b.skuId)) : 1; - } - if (bv === null || bv === undefined || bv === '') return -1; - - const an = Number(av); - const bn = Number(bv); - const bothNumeric = Number.isFinite(an) && Number.isFinite(bn); - const delta = bothNumeric - ? an - bn - : String(av).localeCompare(String(bv), 'zh-CN'); - - return (delta || String(a.skuId).localeCompare(String(b.skuId))) * direction; - }); -} - -// 公司 SABC:复用「商品标签配置」的阈值,按 (品类, 业绩, 日销) 判定规模标签 -async function loadCompanyTagResolver() { - try { - const config = await productTagConfigService.getProductTagConfig(); - const thresholds = config?.scaleThresholds || []; - const byCategory = new Map(); - thresholds.forEach((t) => { - if (t.enabled === false) return; - const key = String(t.category || ''); - if (!byCategory.has(key)) byCategory.set(key, []); - byCategory.get(key).push(t); - }); - return { byCategory, mappings: config?.sabcMappings || [] }; - } catch (error) { - console.warn('[ProductMonitoring] 加载公司标签配置失败,标签列将留空:', error.message); - return null; - } -} - -// 判定:业绩与日销同时命中(左闭右开),按 priority 升序命中即停 -function resolveCompanyTag(config, category, performance, sales) { - if (!config) return null; - const ofCategory = config.byCategory.get(String(category || '')); - if (!ofCategory || !ofCategory.length) return null; - // 业绩缺失时按 0 处理(区间在业务上必然从 0 起),避免整列留空 - const perf = performance === null || performance === undefined ? 0 : Number(performance); - const sale = sales === null || sales === undefined ? 0 : Number(sales); - const scaleTag = productTagConfigService.matchScaleTagOfCategory(ofCategory, perf, sale); - if (!scaleTag || scaleTag === '滞销款') return 'D'; - // 利润等级当前无上游数据源,同规模标签下取最优档作为展示值 - const hit = config.mappings.find((m) => m.scaleTag === scaleTag && m.enabled !== false); - return hit?.sabcTag || null; -} - -// 部门负责人:优先「部门 + SPU」精确配置 → 「部门 + SPU 为空」的部门级默认配置 -function resolveDepartmentOwner(owners, row) { - if (!owners.length) return null; - const deptName = String(row.department || '').trim(); - const spu = String(row.spu || '').trim(); - if (!deptName) return null; - - const ofDept = owners.filter((o) => o.ownerType === 'sku_owner' && o.departmentName === deptName); - if (!ofDept.length) return null; - - const bySpu = spu ? ofDept.find((o) => String(o.spu || '').trim() === spu) : null; - if (bySpu) return bySpu; - return ofDept.find((o) => !o.spu) || null; -} - -async function getMonitoringFilters() { - const latestDate = await getLatestReportDate(); - const [options, departmentRules] = await Promise.all([ - queryFilterOptions(latestDate), - loadDepartmentTagRules(), - ]); - return { - ...options, - companyTags: ['S', 'A', 'B', 'C', 'D'], - departmentTags: DEPARTMENT_TAG_LEVELS, - departmentTagRules: departmentRules, - }; -} - -// ==================== 部门标签配置(系统设置用) ==================== - -async function getDepartmentTagConfig() { - const [rules, owners, departments, users] = await Promise.all([ - loadDepartmentTagRules(), - loadDepartmentSkuOwners(), - database.local.query('SELECT id, name, code, status FROM departments WHERE status = ? ORDER BY sort ASC, id ASC', ['active']), - database.local.query('SELECT id, name, username, role FROM users ORDER BY id ASC LIMIT 1000'), - ]); - - return { - rules: DEPARTMENT_TAG_LEVELS.map((level) => { - const hit = rules.find((r) => r.tagLevel === level); - return { - tagLevel: level, - metricKey: hit?.metricKey || 'sales_qty_7d', - thresholdMin: hit?.thresholdMin ?? null, - thresholdMax: hit?.thresholdMax ?? null, - priority: hit?.priority ?? (DEPARTMENT_TAG_LEVELS.indexOf(level) + 1), - enabled: Boolean(hit), - }; - }), - metricKeys: [...DEPARTMENT_METRIC_KEYS], - owners, - departments: departments.map((d) => ({ id: d.id, name: d.name, code: d.code })), - users: users.map((u) => ({ id: u.id, name: u.name, username: u.username, role: u.role })), - }; -} - -async function saveDepartmentTagConfig(payload = {}) { - // 标签/负责人配置变了,已缓存的数据集里的派生字段立即失效 - invalidateDataset(); - - const rules = Array.isArray(payload.rules) ? payload.rules : null; - const owners = Array.isArray(payload.owners) ? payload.owners : null; - - if (rules) { - validateDepartmentRules(rules); - await database.local.query('DELETE FROM product_tag_rules WHERE tag_scope = ?', [DEPARTMENT_TAG_SCOPE]); - for (const rule of rules) { - if (rule.enabled === false) continue; - await database.local.query( - `INSERT INTO product_tag_rules - (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) - VALUES (?, ?, ?, ?, ?, ?, 1) - ON DUPLICATE KEY UPDATE - metric_key = VALUES(metric_key), - threshold_min = VALUES(threshold_min), - threshold_max = VALUES(threshold_max), - priority = VALUES(priority), - enabled = 1`, - [ - DEPARTMENT_TAG_SCOPE, - String(rule.tagLevel), - DEPARTMENT_METRIC_KEYS.includes(rule.metricKey) ? rule.metricKey : 'sales_qty_7d', - toNumberOrNull(rule.thresholdMin), - toNumberOrNull(rule.thresholdMax), - Number(rule.priority) || 0, - ] - ); - } - } - - if (owners) { - validateDepartmentOwners(owners); - await database.local.query('DELETE FROM department_sku_owners'); - for (const owner of owners) { - await database.local.query( - `INSERT INTO department_sku_owners (department_id, owner_type, user_id, spu) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE department_id = VALUES(department_id)`, - [ - Number(owner.departmentId), - owner.ownerType === 'purchaser' ? 'purchaser' : 'sku_owner', - Number(owner.userId), - owner.spu ? String(owner.spu).trim() : null, - ] - ); - } - } - - return getDepartmentTagConfig(); -} - -function validateDepartmentRules(rules) { - const seen = new Set(); - rules.forEach((rule, index) => { - const line = index + 1; - if (!DEPARTMENT_TAG_LEVELS.includes(String(rule.tagLevel))) { - throw new Error(`第 ${line} 行:部门标签必须是 ${DEPARTMENT_TAG_LEVELS.join(' / ')}`); - } - const min = toNumberOrNull(rule.thresholdMin); - const max = toNumberOrNull(rule.thresholdMax); - if (min !== null && max !== null && min >= max) { - throw new Error(`第 ${line} 行:${rule.tagLevel} 的起点(${min}) 必须小于终点(${max})`); - } - if (seen.has(rule.tagLevel)) { - throw new Error(`第 ${line} 行:部门标签「${rule.tagLevel}」重复`); - } - seen.add(rule.tagLevel); - }); -} - -function validateDepartmentOwners(owners) { - const seen = new Set(); - owners.forEach((owner, index) => { - const line = index + 1; - if (!owner.departmentId) throw new Error(`第 ${line} 行:必须选择部门`); - if (!owner.userId) throw new Error(`第 ${line} 行:必须选择负责人`); - const key = `${owner.departmentId}||${owner.ownerType}||${owner.spu || ''}||${owner.userId}`; - if (seen.has(key)) throw new Error(`第 ${line} 行:部门/负责人/SPU 组合重复`); - seen.add(key); - }); -} - -module.exports = { - ensureProductMonitoringSchema, - getMonitoringList, - getMonitoringFilters, - getDepartmentTagConfig, - saveDepartmentTagConfig, - warmProductMonitoringDataset, - invalidateDataset, - getProductOperators, - matchDepartmentTag, - DEPARTMENT_TAG_LEVELS, - DEPARTMENT_METRIC_KEYS, - SALE_STATUS_OPTIONS, -}; diff --git a/backend/src/services/productTagConfigService.js b/backend/src/services/productTagConfigService.js deleted file mode 100644 index 910e025..0000000 --- a/backend/src/services/productTagConfigService.js +++ /dev/null @@ -1,521 +0,0 @@ -'use strict'; - -// 商品标签配置:规模标签阈值、利润标签阈值、SABC 映射 -// -// 数据来源:《公司SABC标签配置.xlsx》 -// - 区块① 分类 × 业绩/日销区间 → 规模标签(148 行) -// - 区块② 规模标签 × 利润标签 → SABC(25 行) -// -// 判定口径(2026-09-11 评审确认): -// 1. SABC 保留 5 档,含 D -// 2. 利润标签是上游传入的输入值,直接查表;营业利润率仅作展示说明,不参与计算 -// 3. 区间一律「左闭右开」[起点, 终点);规模标签按 priority 升序命中即停 -// 4. 业绩与日销必须同时命中 -// 5. 阈值导入时统一取整 -// 6. 品类为 '-' 的行跳过 -// 7. 暂不实现标签自动重算 - -const database = require('../config/database'); - -const SCALE_TAGS = Object.freeze(['爆款', '热卖款', '动销款', '低动销款']); -const FALLBACK_SCALE_TAG = '滞销款'; -const SCALE_TAG_ORDER = Object.freeze([...SCALE_TAGS, FALLBACK_SCALE_TAG]); -const PROFIT_LEVELS = Object.freeze(['一级', '二级', '三级', '四级', '五级']); -const SABC_TAGS = Object.freeze(['S', 'A', 'B', 'C', 'D']); - -// 区块① 中作为脏数据占位、导入时跳过的品类值 -const IGNORED_CATEGORIES = Object.freeze(['-', '']); - -// 有意不参与评级的品类(2026-09-11 决策 B)。 -// 与「未配置」语义不同:这里是明确决定不评级,不是遗漏,前端要能区分展示。 -// 存在 system_config 里,无需为它单独建表。 -const EXCLUDED_CATEGORIES_CONFIG_KEY = 'product_tag_excluded_categories'; -const DEFAULT_EXCLUDED_CATEGORIES = Object.freeze(['其他 - 配件']); - -// 未评级原因 -const RATING_SKIP_EXCLUDED = 'excluded'; -const RATING_SKIP_UNCONFIGURED = 'unconfigured'; - -async function ensureTable(tableName, createSql) { - await database.local.query(createSql); - const rows = await database.local.query( - `SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = ? - LIMIT 1`, - [tableName] - ); - if (rows.length === 0) { - throw new Error(`Table ${tableName} create failed`); - } -} - -async function ensureProductTagConfigSchema() { - await ensureTable( - 'product_scale_thresholds', - `CREATE TABLE IF NOT EXISTS product_scale_thresholds ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌', - display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)', - scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款', - performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)', - performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)', - sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)', - sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)', - priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停', - sort INT NOT NULL DEFAULT 0, - enabled TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY uk_category_tag (category, scale_tag), - KEY idx_category_priority (category, enabled, priority), - KEY idx_display_name (display_name) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置'` - ); - - await ensureTable( - 'product_profit_levels', - `CREATE TABLE IF NOT EXISTS product_profit_levels ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - level_code VARCHAR(16) NOT NULL COMMENT '一级~五级', - profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)', - description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18', - sort INT NOT NULL DEFAULT 0, - enabled TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY uk_level_code (level_code) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置'` - ); - - await ensureTable( - 'product_sabc_mappings', - `CREATE TABLE IF NOT EXISTS product_sabc_mappings ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - scale_tag VARCHAR(32) NOT NULL, - profit_level VARCHAR(16) NOT NULL, - sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D', - sort INT NOT NULL DEFAULT 0, - enabled TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY uk_scale_profit (scale_tag, profit_level) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射'` - ); -} - -function toNullableNumber(value) { - if (value === null || value === undefined || value === '') return null; - const num = Number(value); - return Number.isFinite(num) ? num : null; -} - -// 决策 5:只对「计算溢出型」脏数据取整,合法的小数(如日销起点 0.5)必须保留。 -// 判据:小数位数 > 2 位(如 7916.34311148132、2063.13142776357)→ 取整; -// 0.5 / 1.25 这类 ≤2 位小数的正常阈值原样保留。 -function normalizeThreshold(value) { - const num = toNullableNumber(value); - if (num === null) return null; - const decimals = String(num).split('.')[1]; - if (decimals && decimals.length > 2) return Math.round(num); - return num; -} - -function toNumber(value, fallback = 0) { - const num = Number(value); - return Number.isFinite(num) ? num : fallback; -} - -function normalizeText(value) { - return String(value === null || value === undefined ? '' : value).trim(); -} - -function assertOneOf(value, allowed, label) { - if (!allowed.includes(value)) { - throw new Error(`${label} 取值非法:${value},允许值 ${allowed.join(' / ')}`); - } -} - -// ==================== 读取 ==================== - -async function loadScaleThresholds() { - const rows = await database.local.query( - `SELECT id, category, display_name, scale_tag, performance_min, performance_max, - sales_min, sales_max, priority, sort, enabled - FROM product_scale_thresholds - ORDER BY category ASC, priority ASC, id ASC` - ); - return rows.map((row) => ({ - id: row.id, - category: row.category, - displayName: row.display_name || row.category, - scaleTag: row.scale_tag, - performanceMin: toNullableNumber(row.performance_min), - performanceMax: toNullableNumber(row.performance_max), - salesMin: toNullableNumber(row.sales_min), - salesMax: toNullableNumber(row.sales_max), - priority: toNumber(row.priority), - sort: toNumber(row.sort), - enabled: Boolean(row.enabled), - })); -} - -async function loadProfitLevels() { - const rows = await database.local.query( - `SELECT id, level_code, profit_rate_max, description, sort, enabled - FROM product_profit_levels - ORDER BY sort ASC, id ASC` - ); - return rows.map((row) => ({ - id: row.id, - levelCode: row.level_code, - profitRateMax: toNullableNumber(row.profit_rate_max), - description: row.description || '', - sort: toNumber(row.sort), - enabled: Boolean(row.enabled), - })); -} - -async function loadSabcMappings() { - const rows = await database.local.query( - `SELECT id, scale_tag, profit_level, sabc_tag, sort, enabled - FROM product_sabc_mappings - ORDER BY sort ASC, id ASC` - ); - return rows.map((row) => ({ - id: row.id, - scaleTag: row.scale_tag, - profitLevel: row.profit_level, - sabcTag: row.sabc_tag, - sort: toNumber(row.sort), - enabled: Boolean(row.enabled), - })); -} - -async function getProductTagConfig() { - const [scaleThresholds, profitLevels, sabcMappings, excludedCategories] = await Promise.all([ - loadScaleThresholds(), - loadProfitLevels(), - loadSabcMappings(), - loadExcludedCategories(), - ]); - return { - scaleThresholds, - profitLevels, - sabcMappings, - excludedCategories, - meta: { - scaleTags: SCALE_TAG_ORDER, - profitLevels: PROFIT_LEVELS, - sabcTags: SABC_TAGS, - }, - }; -} - -// ==================== 排除品类(不参与评级) ==================== - -async function loadExcludedCategories() { - try { - const rows = await database.local.query( - 'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1', - [EXCLUDED_CATEGORIES_CONFIG_KEY] - ); - // 没有配置记录时用默认值(配件不参与评级,决策 B) - if (!rows.length || !rows[0].config_value) return [...DEFAULT_EXCLUDED_CATEGORIES]; - const parsed = JSON.parse(rows[0].config_value); - if (!Array.isArray(parsed)) return [...DEFAULT_EXCLUDED_CATEGORIES]; - return parsed.map((item) => normalizeText(item)).filter(Boolean); - } catch (_error) { - return [...DEFAULT_EXCLUDED_CATEGORIES]; - } -} - -async function saveExcludedCategories(list) { - const normalized = [...new Set((list || []).map((item) => normalizeText(item)).filter(Boolean))]; - const payload = JSON.stringify(normalized); - const existing = await database.local.query( - 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', - [EXCLUDED_CATEGORIES_CONFIG_KEY] - ); - if (existing.length) { - await database.local.query( - 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', - [payload, 'json', EXCLUDED_CATEGORIES_CONFIG_KEY] - ); - } else { - await database.local.query( - 'INSERT INTO system_config (config_key, config_value, config_type) VALUES (?, ?, ?)', - [EXCLUDED_CATEGORIES_CONFIG_KEY, payload, 'json'] - ); - } - return normalized; -} - -// ==================== 校验 ==================== - -function validateScaleThresholds(rows) { - const seen = new Set(); - rows.forEach((row, index) => { - const line = index + 1; - const category = normalizeText(row.category); - if (!category) throw new Error(`第 ${line} 行:品类(jst_sku.category 完整值)不能为空`); - if (IGNORED_CATEGORIES.includes(category)) throw new Error(`第 ${line} 行:品类 "${category}" 已标记为忽略,不应提交`); - assertOneOf(normalizeText(row.scaleTag), SCALE_TAGS, `第 ${line} 行 规模标签`); - - const key = `${category}||${row.scaleTag}`; - if (seen.has(key)) throw new Error(`第 ${line} 行:品类「${category}」的「${row.scaleTag}」重复`); - seen.add(key); - - const pMin = toNullableNumber(row.performanceMin); - const pMax = toNullableNumber(row.performanceMax); - const sMin = toNullableNumber(row.salesMin); - const sMax = toNullableNumber(row.salesMax); - - if (pMin !== null && pMax !== null && pMin >= pMax) { - throw new Error(`第 ${line} 行:业绩起点(${pMin}) 必须小于 业绩终点(${pMax})`); - } - if (sMin !== null && sMax !== null && sMin >= sMax) { - throw new Error(`第 ${line} 行:日销起点(${sMin}) 必须小于 日销终点(${sMax})`); - } - }); -} - -function validateSabcMappings(rows) { - const seen = new Set(); - rows.forEach((row, index) => { - const line = index + 1; - assertOneOf(normalizeText(row.scaleTag), SCALE_TAG_ORDER, `第 ${line} 行 规模标签`); - assertOneOf(normalizeText(row.profitLevel), PROFIT_LEVELS, `第 ${line} 行 利润标签`); - assertOneOf(normalizeText(row.sabcTag), SABC_TAGS, `第 ${line} 行 SABC`); - - const key = `${row.scaleTag}||${row.profitLevel}`; - if (seen.has(key)) throw new Error(`第 ${line} 行:规模标签「${row.scaleTag}」× 利润标签「${row.profitLevel}」重复`); - seen.add(key); - }); -} - -// ==================== 保存(整表替换) ==================== - -async function replaceScaleThresholds(rows) { - validateScaleThresholds(rows); - await database.local.query('DELETE FROM product_scale_thresholds'); - if (!rows.length) return 0; - - const values = []; - const placeholders = rows.map((row) => { - // priority 由文档顺序推导:爆款=1 热卖款=2 动销款=3 低动销款=4 - const priority = SCALE_TAGS.indexOf(normalizeText(row.scaleTag)) + 1; - values.push( - normalizeText(row.category), - normalizeText(row.displayName) || normalizeText(row.category), - normalizeText(row.scaleTag), - normalizeThreshold(row.performanceMin), - normalizeThreshold(row.performanceMax), - normalizeThreshold(row.salesMin), - normalizeThreshold(row.salesMax), - priority, - toNumber(row.sort, priority), - row.enabled === false ? 0 : 1 - ); - return '(?,?,?,?,?,?,?,?,?,?)'; - }); - - await database.local.query( - `INSERT INTO product_scale_thresholds - (category, display_name, scale_tag, performance_min, performance_max, - sales_min, sales_max, priority, sort, enabled) - VALUES ${placeholders.join(',')}`, - values - ); - return rows.length; -} - -async function replaceProfitLevels(rows) { - const list = rows && rows.length ? rows : PROFIT_LEVELS.map((levelCode) => ({ levelCode })); - list.forEach((row, index) => { - assertOneOf(normalizeText(row.levelCode), PROFIT_LEVELS, `第 ${index + 1} 行 利润标签`); - }); - - const seen = new Set(); - list.forEach((row, index) => { - const code = normalizeText(row.levelCode); - if (seen.has(code)) throw new Error(`第 ${index + 1} 行:利润标签「${code}」重复`); - seen.add(code); - }); - - await database.local.query('DELETE FROM product_profit_levels'); - const values = []; - const placeholders = list.map((row, index) => { - values.push( - normalizeText(row.levelCode), - toNullableNumber(row.profitRateMax), - normalizeText(row.description) || null, - toNumber(row.sort, index + 1), - row.enabled === false ? 0 : 1 - ); - return '(?,?,?,?,?)'; - }); - - await database.local.query( - `INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled) - VALUES ${placeholders.join(',')}`, - values - ); - return list.length; -} - -async function replaceSabcMappings(rows) { - validateSabcMappings(rows); - await database.local.query('DELETE FROM product_sabc_mappings'); - if (!rows.length) return 0; - - const values = []; - const placeholders = rows.map((row, index) => { - values.push( - normalizeText(row.scaleTag), - normalizeText(row.profitLevel), - normalizeText(row.sabcTag), - toNumber(row.sort, index + 1), - row.enabled === false ? 0 : 1 - ); - return '(?,?,?,?,?)'; - }); - - await database.local.query( - `INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled) - VALUES ${placeholders.join(',')}`, - values - ); - return rows.length; -} - -// 一次性保存三个区块(前端「保存配置」按钮) -async function saveProductTagConfig(payload = {}) { - const result = {}; - if (Array.isArray(payload.scaleThresholds)) { - result.scaleThresholdCount = await replaceScaleThresholds(payload.scaleThresholds); - } - if (Array.isArray(payload.profitLevels)) { - result.profitLevelCount = await replaceProfitLevels(payload.profitLevels); - } - if (Array.isArray(payload.sabcMappings)) { - result.sabcMappingCount = await replaceSabcMappings(payload.sabcMappings); - } - if (Array.isArray(payload.excludedCategories)) { - result.excludedCategoryCount = (await saveExcludedCategories(payload.excludedCategories)).length; - } - return result; -} - -// ==================== 运行时判定 ==================== - -// 决策 3 + 4:左闭右开,业绩与日销同时命中;决策 2:按 priority 升序命中即停 -// -// 重要:必须区分两种「没命中」—— -// ① 该品类有配置阈值,但数值全部落在区间外 → 真·滞销款(兜底档) -// ② 该品类**根本没有配置任何阈值** → 返回 null(未评级),不能算滞销款 -// 否则未配置的品类(如「其他 - 配件」47 万 SKU)会被整批误判成滞销款。 -function matchScaleTagOfCategory(thresholdsOfCategory, performance, sales) { - const ordered = [...(thresholdsOfCategory || [])] - .filter((row) => row.enabled !== false) - .sort((a, b) => (a.priority - b.priority) || (a.id - b.id)); - - if (!ordered.length) return null; - - for (const row of ordered) { - const pIn = (row.performanceMin === null || performance >= row.performanceMin) - && (row.performanceMax === null || performance < row.performanceMax); - const sIn = (row.salesMin === null || sales >= row.salesMin) - && (row.salesMax === null || sales < row.salesMax); - if (pIn && sIn) return row.scaleTag; - } - return FALLBACK_SCALE_TAG; -} - -function buildScaleThresholdIndex(thresholds) { - const index = new Map(); - (thresholds || []).forEach((row) => { - const key = normalizeText(row.category); - if (!index.has(key)) index.set(key, []); - index.get(key).push(row); - }); - return index; -} - -function buildSabcIndex(mappings) { - const index = new Map(); - (mappings || []).forEach((row) => { - if (row.enabled === false) return; - index.set(`${normalizeText(row.scaleTag)}||${normalizeText(row.profitLevel)}`, row.sabcTag); - }); - return index; -} - -// 批量解析:items = [{ category, performance, sales, profitLevel }] -async function resolveProductTags(items) { - const [thresholds, mappings, excluded] = await Promise.all([ - loadScaleThresholds(), - loadSabcMappings(), - loadExcludedCategories(), - ]); - const thresholdIndex = buildScaleThresholdIndex(thresholds); - const sabcIndex = buildSabcIndex(mappings); - const excludedSet = new Set(excluded); - - return (items || []).map((item) => { - const category = normalizeText(item.category); - - // 有意排除的品类:直接返回未评级,且标明原因,不与「未配置」混淆 - if (excludedSet.has(category)) { - return { - ...item, - scaleTag: null, - sabcTag: null, - rated: false, - skippedReason: RATING_SKIP_EXCLUDED, - }; - } - - const rows = thresholdIndex.get(category); - const scaleTag = matchScaleTagOfCategory(rows, toNullableNumber(item.performance), toNullableNumber(item.sales)); - const profitLevel = normalizeText(item.profitLevel); - const sabcTag = (scaleTag && profitLevel) - ? (sabcIndex.get(`${scaleTag}||${profitLevel}`) || null) - : null; - - return { - ...item, - scaleTag, - sabcTag, - rated: scaleTag !== null, - // 该品类没有任何阈值配置 → 未评级(不能算滞销款) - skippedReason: scaleTag === null ? RATING_SKIP_UNCONFIGURED : null, - }; - }); -} - -module.exports = { - SCALE_TAGS, - SCALE_TAG_ORDER, - FALLBACK_SCALE_TAG, - PROFIT_LEVELS, - SABC_TAGS, - IGNORED_CATEGORIES, - DEFAULT_EXCLUDED_CATEGORIES, - EXCLUDED_CATEGORIES_CONFIG_KEY, - RATING_SKIP_EXCLUDED, - RATING_SKIP_UNCONFIGURED, - ensureProductTagConfigSchema, - getProductTagConfig, - saveProductTagConfig, - loadExcludedCategories, - saveExcludedCategories, - resolveProductTags, - matchScaleTagOfCategory, - // 供单元测试或导入脚本复用 - normalizeThreshold, - validateScaleThresholds, - validateSabcMappings, -}; diff --git a/backend/src/services/retiredFeatures.js b/backend/src/services/retiredFeatures.js index c9b130d..c3bba89 100644 --- a/backend/src/services/retiredFeatures.js +++ b/backend/src/services/retiredFeatures.js @@ -2,12 +2,16 @@ const RETIRED_PAGE_KEYS = [ 'planning_upload', 'market_insight', 'agent', 'ai_skill_tags', 'store_checkup', 'review_analysis', 'customer_service_checkup', 'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video', + 'product_monitoring', 'ops_b_incentive', 'marketing_events', 'promotion_module', 'product_analysis', ]; const RETIRED_ENTRY_KEYS = new Set([ 'market-insight', 'agent', 'ai-skill-tags', 'platform-switch', 'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup', 'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video', 'desktop-client-releases', + 'product-monitoring', 'ops-b-incentive', + 'marketing-management', 'marketing-events', 'promotion-module', + 'product-management', 'product-analysis', ]); const RETIRED_CONFIG_KEYS = [ 'openclaw_chat_config', 'openclaw_chat_skill_tags', @@ -15,6 +19,7 @@ const RETIRED_CONFIG_KEYS = [ 'cs_quality_ai_config', 'desktop_client_releases', 'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url', 'ai_buyer_show_vision_model', 'ai_buyer_show_review_model', + 'product_tag_excluded_categories', ]; const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database']; @@ -48,6 +53,15 @@ async function cleanupRetiredFeatureSettings(db) { await db.query(`DELETE FROM database_configs WHERE name IN (${RETIRED_DATABASE_CONFIG_NAMES.map(() => '?').join(',')})`, RETIRED_DATABASE_CONFIG_NAMES); await db.query('DROP TABLE IF EXISTS pricing_calculator_history'); await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs'); + await db.query('DROP TABLE IF EXISTS ops_b_incentive_snapshots'); + await db.query('DROP TABLE IF EXISTS ops_b_incentive_periods'); + await db.query('DROP TABLE IF EXISTS product_analysis_user_prefs'); + await db.query('DROP TABLE IF EXISTS pdd_goods_first_seen'); + await db.query('DROP TABLE IF EXISTS department_sku_owners'); + await db.query('DROP TABLE IF EXISTS product_tag_rules'); + await db.query('DROP TABLE IF EXISTS product_sabc_mappings'); + await db.query('DROP TABLE IF EXISTS product_profit_levels'); + await db.query('DROP TABLE IF EXISTS product_scale_thresholds'); const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']); if (rows.length) { const current = rows[0].config_value; diff --git a/backend/test/dashboardRoutes.test.js b/backend/test/dashboardRoutes.test.js new file mode 100644 index 0000000..eb4f99a --- /dev/null +++ b/backend/test/dashboardRoutes.test.js @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const Module = require('node:module'); +const express = require('express'); + +function loadDashboardRouter() { + const originalLoad = Module._load; + Module._load = function load(id, parent, ...rest) { + if (parent?.filename.endsWith('/api/dashboard.js') && id.endsWith('/jianSunDatabase')) { + return { + query: async (sql) => { + if (sql.includes('SELECT DISTINCT shopName')) return [{ shopName: '测试店铺' }]; + if (sql.includes('FROM dbo.pdd_DataGoods g')) { + return [{ + statDate: '2026-09-12', latestDate: '2026-09-12', payAmount: 100, + buyerCount: 2, visitorCount: 10, goodsPv: 20, payGoodsQty: 3, + payOrderCount: 2, favoriteCount: 1, impressionCount: 30, + }]; + } + if (sql.includes('FROM dbo.pdd_MallServiceScore')) return [{ shopCount: 1, cstmrServScore: 4.8 }]; + if (sql.includes('FROM dbo.pdd_ShopStars')) return [{ shopCount: 1, mallStarTomms: 5 }]; + return []; + }, + }; + } + return originalLoad.call(this, id, parent, ...rest); + }; + try { + return require('../src/api/dashboard'); + } finally { + Module._load = originalLoad; + } +} + +test('dashboard keeps its read-only data endpoints after product analysis is retired', async () => { + const app = express(); + app.use('/api/dashboard', loadDashboardRouter()); + const server = await new Promise((resolve) => { + const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); + }); + try { + const base = `http://127.0.0.1:${server.address().port}`; + const meta = await (await fetch(`${base}/api/dashboard/meta`)).json(); + assert.deepEqual(meta.data.shops, ['测试店铺']); + + const summary = await (await fetch(`${base}/api/dashboard/summary?period=day`)).json(); + assert.equal(summary.success, true); + assert.equal(summary.data.latestDate, '2026-09-12'); + assert.equal(summary.data.metrics.find((item) => item.key === 'payAmount').value, 100); + assert.equal((await fetch(`${base}/api/product-analysis/products`)).status, 404); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/backend/test/desktopSessionService.test.js b/backend/test/desktopSessionService.test.js index b48e80c..4c6dbe6 100644 --- a/backend/test/desktopSessionService.test.js +++ b/backend/test/desktopSessionService.test.js @@ -92,11 +92,9 @@ test('desktop capabilities follow resolved page permissions', () => { shopManagement: false, execution: false, logs: true, - marketingEvents: false, - promotion: false, productTest: false, }); - assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true); + assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).productTest, true); }); test('SSO ticket is consumed exactly once and produces a normal web token', async () => { diff --git a/backend/test/retiredFeatures.test.js b/backend/test/retiredFeatures.test.js index 7619ad1..bacfc4e 100644 --- a/backend/test/retiredFeatures.test.js +++ b/backend/test/retiredFeatures.test.js @@ -12,6 +12,10 @@ test('retired sidebar cleanup is recursive and idempotent', () => { { key: 'agent' }, { key: 'store-management', children: [{ key: 'store-checkup' }] }, { key: 'operation-tools', children: [{ key: 'pricing-calculator' }] }, + { key: 'marketing-management', children: [{ key: 'marketing-events' }] }, + { key: 'product-management', children: [{ key: 'product-analysis' }] }, + { key: 'product-monitoring' }, + { key: 'ops-b-incentive' }, { key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'desktop-client-releases' }, { key: 'settings', label: '管理设置' }] }, ] }); const clean = pruneRetiredSidebarConfig(saved); @@ -20,6 +24,7 @@ test('retired sidebar cleanup is recursive and idempotent', () => { assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid'); assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true); assert.equal(isRetiredSystemConfigKey('cs_quality_ai_config'), true); + assert.equal(isRetiredSystemConfigKey('product_tag_excluded_categories'), true); assert.equal(isRetiredDatabaseConfigName('ai_buyer_show_database'), true); assert.equal(isRetiredSystemConfigKey('site_name'), false); }); @@ -28,12 +33,12 @@ test('upgrade removes only retired settings and permission rows', async () => { const calls = []; await cleanupRetiredFeatureSettings({ query: async (sql, params) => { calls.push({ sql, params }); - return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : []; + return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"},{"key":"settings"}]}' }] : []; } }); const deletes = calls.filter((call) => call.sql.startsWith('DELETE')); assert.equal(deletes.length, 5); assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0)); - assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 2); + assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 11); const update = calls.find((call) => call.sql.startsWith('UPDATE')); - assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]); + assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'settings' }]); }); diff --git a/backend/test/retiredRoutes.test.js b/backend/test/retiredRoutes.test.js index 0a905c3..ad9025f 100644 --- a/backend/test/retiredRoutes.test.js +++ b/backend/test/retiredRoutes.test.js @@ -3,14 +3,11 @@ const assert = require('node:assert/strict'); const Module = require('node:module'); const express = require('express'); -test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => { +test('retired analysis endpoints return 404 while retained selection endpoint works', async () => { const originalLoad = Module._load; Module._load = function (id, parent, ...rest) { if (parent?.filename.endsWith('/api/planningUpload.js')) { if (id.endsWith('/pageAccess')) return { getResolvedUserWithPageAccess: async () => ({ role: 'admin' }) }; - if (id.endsWith('/opsBIncentiveBoardService')) return { - getOpsBIncentiveBoard: async () => ({ rows: [] }), getOpsBIncentiveBatches: async () => [], getOpsBIncentiveDaily: async () => [], - }; if (id.endsWith('/alibaba1688ProductService')) return { getAlibaba1688Product: async (id) => ({ id }) }; if (id.endsWith('/alibaba1688ProductEncodingRegistry')) return { attachEncodingDetail: async (value) => value }; } @@ -24,10 +21,10 @@ test('retired market endpoints return 404 while retained incentive and calculato const server = await new Promise((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); }); try { const base = `http://127.0.0.1:${server.address().port}/api/planning-upload`; - for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans']) { + for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans', '/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily']) { assert.equal((await fetch(base + path)).status, 404, path); } - for (const path of ['/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily', '/selection-fission/1688-products/123']) { + for (const path of ['/selection-fission/1688-products/123']) { const response = await fetch(base + path); assert.equal(response.status, 200, path); assert.equal((await response.json()).success, true); diff --git a/frontend/src/api/admin.js b/frontend/src/api/admin.js index 77ebc86..220f839 100644 --- a/frontend/src/api/admin.js +++ b/frontend/src/api/admin.js @@ -143,18 +143,6 @@ export const testSelectionImageGenerationSettings = () => { -// 商品标签配置(规模标签 / 利润标签 / SABC 映射) -export const getProductTagConfig = () => { - return request.get('/api/admin/product-tag-config'); -}; - -export const updateProductTagConfig = (data) => { - return request.put('/api/admin/product-tag-config', data); -}; - - - - export const getAuthConfig = () => { return request.get('/api/admin/auth-config'); }; diff --git a/frontend/src/api/dashboard.js b/frontend/src/api/dashboard.js new file mode 100644 index 0000000..aade18d --- /dev/null +++ b/frontend/src/api/dashboard.js @@ -0,0 +1,16 @@ +import request from './request'; + +let dashboardMetaInFlight = null; + +export const getDashboardMeta = () => { + if (!dashboardMetaInFlight) { + dashboardMetaInFlight = request.get('/api/dashboard/meta').finally(() => { + dashboardMetaInFlight = null; + }); + } + return dashboardMetaInFlight; +}; + +export const getDashboardSummary = (params, config = {}) => ( + request.get('/api/dashboard/summary', { ...config, params }) +); diff --git a/frontend/src/api/marketingEvents.js b/frontend/src/api/marketingEvents.js deleted file mode 100644 index 36d5fdf..0000000 --- a/frontend/src/api/marketingEvents.js +++ /dev/null @@ -1,17 +0,0 @@ -import request from './request'; - -export const getMarketingEventsList = (params) => ( - request.get('/api/marketing-events/list', { params }) -); - -export const getMarketingEventsLatestDate = () => ( - request.get('/api/marketing-events/latest-date') -); - -export const getMarketingEventsSummary = (params) => ( - request.get('/api/marketing-events/summary', { params }) -); - -export const getMarketingEventsStatusCounts = () => ( - request.get('/api/marketing-events/status-counts') -); diff --git a/frontend/src/api/planningUpload.js b/frontend/src/api/planningUpload.js index 051fffb..0f46465 100644 --- a/frontend/src/api/planningUpload.js +++ b/frontend/src/api/planningUpload.js @@ -2,21 +2,6 @@ import request from './request'; const PLANNING_TABLE_TIMEOUT_MS = 3 * 60 * 1000; -export const getOpsBIncentiveBatches = () => request.get( - '/api/planning-upload/analysis/ops-b-incentive/batches', - { timeout: 60 * 1000 } -); - -export const getOpsBIncentiveBoard = (params = {}) => request.get( - '/api/planning-upload/analysis/ops-b-incentive', - { params, timeout: PLANNING_TABLE_TIMEOUT_MS } -); - -export const getOpsBIncentiveDaily = (params = {}) => request.get( - '/api/planning-upload/analysis/ops-b-incentive/daily', - { params, timeout: PLANNING_TABLE_TIMEOUT_MS } -); - export const getPlanningAlibaba1688ProductDetail = (productId) => { return request.get(`/api/planning-upload/selection-fission/1688-products/${productId}`, { headers: { diff --git a/frontend/src/api/productAnalysis.js b/frontend/src/api/productAnalysis.js deleted file mode 100644 index a7baef2..0000000 --- a/frontend/src/api/productAnalysis.js +++ /dev/null @@ -1,42 +0,0 @@ -import request from './request'; - -let productAnalysisMetaInFlight = null; -const productAnalysisProductsInFlight = new Map(); - -export const getProductAnalysisMeta = () => { - if (!productAnalysisMetaInFlight) { - productAnalysisMetaInFlight = request.get('/api/product-analysis/meta') - .finally(() => { - productAnalysisMetaInFlight = null; - }); - } - return productAnalysisMetaInFlight; -}; - -export const getProductAnalysisSignalThresholds = () => ( - request.get('/api/product-analysis/signal-thresholds', { silentError: true }) -); - -export const saveProductAnalysisSignalThresholds = (thresholds) => ( - request.put('/api/product-analysis/signal-thresholds', { thresholds }, { silentError: true }) -); - -export const getDevelopmentDashboard = (params, config = {}) => ( - request.get('/api/product-analysis/dashboard', { ...config, params }) -); - -export const getProductAnalysisProducts = (params) => { - const requestKey = JSON.stringify(params || {}); - if (!productAnalysisProductsInFlight.has(requestKey)) { - const inFlight = request.get('/api/product-analysis/products', { params }) - .finally(() => { - productAnalysisProductsInFlight.delete(requestKey); - }); - productAnalysisProductsInFlight.set(requestKey, inFlight); - } - return productAnalysisProductsInFlight.get(requestKey); -}; - -export const getProductAnalysisTab = (goodsId, tabKey, params) => ( - request.get(`/api/product-analysis/products/${encodeURIComponent(goodsId)}/tabs/${tabKey}`, { params }) -); diff --git a/frontend/src/api/productMonitoring.js b/frontend/src/api/productMonitoring.js deleted file mode 100644 index 1f2a523..0000000 --- a/frontend/src/api/productMonitoring.js +++ /dev/null @@ -1,34 +0,0 @@ -import request from './request'; - -const LIST_TIMEOUT_MS = 3 * 60 * 1000; -const META_TIMEOUT_MS = 60 * 1000; - -// 明细列表(公司标签 / 部门标签 / 负责人 / 汇总) -export const getProductMonitoringList = (params = {}) => request.get( - '/api/product-monitoring/list', - { params, timeout: LIST_TIMEOUT_MS } -); - -// 筛选下拉选项(品类 / 供应商 / 仓库 / 采购负责人 / 销售状态) -export const getProductMonitoringFilters = () => request.get( - '/api/product-monitoring/filters', - { timeout: META_TIMEOUT_MS } -); - -// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道) -export const getProductMonitoringOperators = (sku) => request.get( - '/api/product-monitoring/operators', - { params: { sku }, timeout: META_TIMEOUT_MS } -); - -// 部门标签(G-S/G-A/G-B/G-C)+ 部门 SKU 负责人配置 -export const getDepartmentTagConfig = () => request.get( - '/api/product-monitoring/department-config', - { timeout: META_TIMEOUT_MS } -); - -export const saveDepartmentTagConfig = (payload = {}) => request.put( - '/api/product-monitoring/department-config', - payload, - { timeout: META_TIMEOUT_MS } -); diff --git a/frontend/src/api/promotionModule.js b/frontend/src/api/promotionModule.js deleted file mode 100644 index 93e2d52..0000000 --- a/frontend/src/api/promotionModule.js +++ /dev/null @@ -1,17 +0,0 @@ -import request from './request'; - -export const getPromotionModuleList = (params) => ( - request.get('/api/promotion-module/list', { params }) -); - -export const getPromotionModuleLatestDate = () => ( - request.get('/api/promotion-module/latest-date') -); - -export const getPromotionModuleSummary = (params) => ( - request.get('/api/promotion-module/summary', { params }) -); - -export const getPromotionModuleShops = () => ( - request.get('/api/promotion-module/shops') -); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 78b4cca..2e77693 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -2,22 +2,7 @@ export const DEFAULT_SIDEBAR_CONFIG = { collapsedByDefault: false, darkModeByDefault: false, entries: [ - { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 }, - { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 }, { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 }, - { - key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true, - children: [ - { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 }, - { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 }, - ], - }, - { - key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true, - children: [ - { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, - ], - }, { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, { key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, @@ -45,28 +30,6 @@ const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [ const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries)); const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries)); -// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置 -const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板'; -const OPS_B_INCENTIVE_LEGACY_ORDER = 6; -const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']); -// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并 -const extractPromotedTopLevelEntries = (entries = []) => { - const promoted = []; - const nextEntries = entries.map((entry) => { - if (!Array.isArray(entry?.children)) return entry; - const children = []; - entry.children.forEach((child) => { - if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) { - promoted.push(child); - } else { - children.push(child); - } - }); - return children.length === entry.children.length ? entry : { ...entry, children }; - }); - return { nextEntries, promoted }; -}; - const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key)); const configuredKeys = new Set(); @@ -82,18 +45,6 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { if (typeof defaultEntry.requiresAdmin !== 'undefined') { merged.requiresAdmin = defaultEntry.requiresAdmin; } - if ( - defaultEntry.key === 'ops-b-incentive' - && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL) - ) { - merged.label = defaultEntry.label; - } - if ( - defaultEntry.key === 'ops-b-incentive' - && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER) - ) { - merged.order = defaultEntry.order; - } const hasDefaultChildren = Array.isArray(defaultEntry.children); const hasConfiguredChildren = Array.isArray(configuredEntry.children); @@ -130,15 +81,10 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { const mergeSidebarConfigWithDefaults = (config) => { const defaults = cloneDefaultConfig(); - const nestedEntries = (config?.entries || []); - const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries); - const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean)); - const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted; - return { ...defaults, ...config, - entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]), + entries: mergeSidebarEntries(defaults.entries, config?.entries || []), }; }; diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index f76eecb..a806b85 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -72,15 +72,6 @@ const SIDEBAR_ICON_MAP = { const SIDEBAR_ROUTE_MAP = { 'dashboard-preview': { path: '/planning/dev-style-preview' }, - 'marketing-events': { - path: '/planning/operation/marketing-events', - activePath: '/planning/operation/marketing-events', - matchMode: 'prefix', - }, - 'promotion-module': { path: '/planning/operation/promotion-module' }, - 'product-monitoring': { path: '/planning/operation/product-monitoring' }, - 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, - 'product-analysis': { path: '/planning/operation/product-analysis' }, 'work-orders': { path: '/planning/operation/work-orders' }, settings: { path: '/settings' }, 'online-desktop-users': { path: '/settings/online-desktop-users' }, diff --git a/frontend/src/pages/DevelopmentStylePreview/index.jsx b/frontend/src/pages/DevelopmentStylePreview/index.jsx index f4559c5..d88e520 100644 --- a/frontend/src/pages/DevelopmentStylePreview/index.jsx +++ b/frontend/src/pages/DevelopmentStylePreview/index.jsx @@ -24,7 +24,7 @@ import { SycmSimpleTable, SycmSparkline, } from '../../components/Common'; -import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis'; +import { getDashboardMeta, getDashboardSummary } from '../../api/dashboard'; import './stylePreview.css'; const overviewMetrics = [ @@ -180,7 +180,7 @@ const DevelopmentStylePreview = () => { useEffect(() => { let active = true; - getProductAnalysisMeta().then((response) => { + getDashboardMeta().then((response) => { if (!active) return; const shops = Array.isArray(response?.data?.shops) ? response.data.shops : []; setShopOptions([ @@ -202,7 +202,7 @@ const DevelopmentStylePreview = () => { const params = customStartDate && customEndDate ? { startDate: customStartDate, endDate: customEndDate, shopName: shopName || undefined } : { period: PERIOD_MAP[activePeriod] || 'week', shopName: shopName || undefined }; - getDevelopmentDashboard(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => { + getDashboardSummary(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => { const payload = response?.data || {}; setDashboard(payload); }).catch((error) => { diff --git a/frontend/src/pages/MarketingEvents/index.jsx b/frontend/src/pages/MarketingEvents/index.jsx deleted file mode 100644 index a497666..0000000 --- a/frontend/src/pages/MarketingEvents/index.jsx +++ /dev/null @@ -1,492 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Button, - DatePicker, - Form, - Input, - Segmented, - Select, - Spin, - Table, - Tag, -} from 'antd'; -import { - ReloadOutlined, - SearchOutlined, -} from '@ant-design/icons'; -import { SycmBusinessPanel, SycmDashboardMetric, SycmPage } from '../../components/Common'; -import { - getMarketingEventsList, - getMarketingEventsLatestDate, - getMarketingEventsSummary, -} from '../../api/marketingEvents'; -import './style.css'; - -const { RangePicker } = DatePicker; - -function formatTimestamp(ts) { - if (!ts) return '-'; - const num = Number(ts); - if (!Number.isFinite(num)) return '-'; - const date = new Date(num > 1e12 ? num : num * 1000); - if (Number.isNaN(date.getTime())) return '-'; - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - const hh = String(date.getHours()).padStart(2, '0'); - const mm = String(date.getMinutes()).padStart(2, '0'); - return `${y}-${m}-${d} ${hh}:${mm}`; -} - -function formatPrice(value) { - if (value === null || value === undefined || value === '') return '-'; - const num = Number(value); - if (!Number.isFinite(num)) return '-'; - return `¥${num.toFixed(2)}`; -} - -function formatNumber(value) { - if (value === null || value === undefined) return '-'; - const num = Number(value); - if (!Number.isFinite(num)) return '-'; - return num.toLocaleString('zh-CN'); -} - -const STATUS_MAP = { - 501: { label: '报名成功', color: 'success' }, - 502: { label: '审核中', color: 'processing' }, - 503: { label: '已拒绝', color: 'error' }, - 504: { label: '已取消', color: 'default' }, - 505: { label: '已结束', color: 'warning' }, -}; - -function getStatusInfo(status) { - if (STATUS_MAP[status]) return STATUS_MAP[status]; - return { label: `状态 ${status}`, color: 'default' }; -} - -const MarketingEvents = () => { - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false); - const [dataSource, setDataSource] = useState([]); - const [total, setTotal] = useState(0); - const [statusCounts, setStatusCounts] = useState({ all: 0 }); - const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); - const [sorter, setSorter] = useState({ field: 'activity_goods_start_time', order: 'descend' }); - const [statusFilter, setStatusFilter] = useState('all'); - const [groupByGoods, setGroupByGoods] = useState(true); - const [queryFilters, setQueryFilters] = useState({}); - const [latestDate, setLatestDate] = useState(''); - const [summary, setSummary] = useState(null); - const [summaryLoading, setSummaryLoading] = useState(false); - - const fetchLatestDate = useCallback(async () => { - try { - const res = await getMarketingEventsLatestDate(); - if (res?.data?.date) { - setLatestDate(res.data.date); - return res.data.date; - } - } catch { - // 忽略错误 - } - return ''; - }, []); - - const fetchSummary = useCallback(async (date) => { - setSummaryLoading(true); - try { - const res = await getMarketingEventsSummary({ date }); - if (res?.data) { - setSummary(res.data); - } - } catch { - setSummary(null); - } finally { - setSummaryLoading(false); - } - }, []); - - useEffect(() => { - fetchLatestDate().then((date) => { - if (date) fetchSummary(date); - }); - }, [fetchLatestDate, fetchSummary]); - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const params = { - page: pagination.current, - pageSize: pagination.pageSize, - sortBy: sorter.field, - sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC', - ...queryFilters, - }; - if (statusFilter !== 'all') { - params.status = statusFilter; - } - const result = await getMarketingEventsList(params); - const data = result?.data; - if (data) { - setDataSource(data.list || []); - setTotal(data.total || 0); - if (data.statusCounts) { - setStatusCounts(data.statusCounts); - } - if (data.filterDate) { - setLatestDate(data.filterDate); - fetchSummary(data.filterDate); - } - } - } catch { - setDataSource([]); - setTotal(0); - } finally { - setLoading(false); - } - }, [pagination, sorter, statusFilter, queryFilters, fetchSummary]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - const handleTableChange = (pag, _filters, sort) => { - setPagination({ current: pag.current, pageSize: pag.pageSize }); - if (sort.field) { - setSorter({ field: sort.field, order: sort.order || 'descend' }); - } - }; - - const handleReset = () => { - form.resetFields(); - setQueryFilters({}); - setStatusFilter('all'); - setPagination({ current: 1, pageSize: 20 }); - setSorter({ field: 'activity_goods_start_time', order: 'descend' }); - }; - - const handleFinish = (values) => { - const filters = {}; - if (values.shopName) filters.shopName = values.shopName; - if (values.activityType) filters.activityType = values.activityType; - if (values.activityName) filters.activityName = values.activityName; - if (values.activityId) filters.activityId = values.activityId; - if (values.goodsName) filters.goodsName = values.goodsName; - if (values.goodsId) filters.goodsId = values.goodsId; - if (values.dateRange?.length === 2) { - filters.dateStart = values.dateRange[0].format('YYYY-MM-DD'); - filters.dateEnd = values.dateRange[1].format('YYYY-MM-DD'); - } - setQueryFilters(filters); - setPagination((prev) => ({ ...prev, current: 1 })); - }; - - const statusOptions = useMemo(() => [ - { label: `全部 ${statusCounts.all || 0}`, value: 'all' }, - { label: `报名成功 ${statusCounts[501] || 0}`, value: 501 }, - { label: `审核中 ${statusCounts[502] || 0}`, value: 502 }, - { label: `已拒绝 ${statusCounts[503] || 0}`, value: 503 }, - { label: `已取消 ${statusCounts[504] || 0}`, value: 504 }, - { label: `已结束 ${statusCounts[505] || 0}`, value: 505 }, - ], [statusCounts]); - - // 按商品聚合:同一商品多个活动时,最低价活动作为主行优先展示,其余折叠进展开行 - const displayData = useMemo(() => { - const priceOf = (row) => { - const num = Number(row?.minPrice); - return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY; - }; - if (!groupByGoods) { - return dataSource.map((row) => ({ ...row, activityCount: 1, childActivities: null })); - } - const groups = new Map(); - dataSource.forEach((row) => { - const key = String(row.goodsId || row.id); - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(row); - }); - const list = []; - groups.forEach((rows) => { - const sorted = [...rows].sort((left, right) => priceOf(left) - priceOf(right)); - const [primary, ...rest] = sorted; - list.push({ - ...primary, - activityCount: sorted.length, - childActivities: rest.length ? rest : null, - }); - }); - return list; - }, [dataSource, groupByGoods]); - - const summaryMetrics = useMemo(() => { - if (!summary) return []; - return [ - { key: 'totalRecords', label: '活动记录数', value: formatNumber(summary.totalRecords) }, - { key: 'successCount', label: '报名成功', value: formatNumber(summary.successCount) }, - { key: 'successRate', label: '报名成功率', value: `${summary.successRate}%` }, - { key: 'goodsCount', label: '参与商品数', value: formatNumber(summary.goodsCount) }, - { key: 'shopCount', label: '参与店铺数', value: formatNumber(summary.shopCount) }, - { key: 'activityIdCount', label: '活动数', value: formatNumber(summary.activityIdCount) }, - { key: 'activityTypeCount', label: '活动类型', value: formatNumber(summary.activityTypeCount) }, - { key: 'priceRange', label: '价格区间', value: summary.globalMinPrice !== '-' ? `${formatPrice(summary.globalMinPrice)} ~ ${formatPrice(summary.globalMaxPrice)}` : '-' }, - ]; - }, [summary]); - - const columns = useMemo(() => [ - { - title: '商品图片', - dataIndex: 'thumbUrl', - key: 'thumbUrl', - width: 80, - fixed: 'left', - render: (value, record) => ( - value ? ( - {record.goodsName - ) : ( - 暂无 - ) - ), - }, - { - title: '商品名称', - dataIndex: 'goodsName', - key: 'goodsName', - width: 200, - ellipsis: true, - render: (value, record) => ( -
- {value ? {value} : '-'} - ID: {record.goodsId || '-'} -
- ), - }, - { - title: '活动名称', - dataIndex: 'activityName', - key: 'activityName', - width: 200, - ellipsis: true, - render: (value, record) => ( -
- {value ? {value} : '-'} - ID: {record.activityId || '-'} - {record.activityCount > 1 ? ( - 共{record.activityCount}个活动 · 已按最低价优先 - ) : null} -
- ), - }, - { - 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 {info.label}; - }, - }, - ], []); - - const childActivityColumns = [ - { title: '活动名称', dataIndex: 'activityName', width: 200, ellipsis: true, render: (value, record) => ( -
- {value ? {value} : '-'} - ID: {record.activityId || '-'} -
- ) }, - { 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 {info.label}; - } }, - ]; - - return ( - - -
-

营销活动

- 查看拼多多平台活动报名记录 - {latestDate && ( - 最新数据日期:{latestDate} - )} -
- - -
- {summaryMetrics.map((item) => ( - - ))} - {!summary && !summaryLoading && ( - 暂无汇总数据 - )} -
-
- -
- - - - - - - - - - - - - - - - - - - - - -
- - -
-
- -
- { - setStatusFilter(value); - setPagination((prev) => ({ ...prev, current: 1 })); - }} - /> - setGroupByGoods(value === 'group')} - /> -
- - - ( -
- ), - 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" - /> - - - - ); -}; - -export default MarketingEvents; diff --git a/frontend/src/pages/MarketingEvents/style.css b/frontend/src/pages/MarketingEvents/style.css deleted file mode 100644 index 510366d..0000000 --- a/frontend/src/pages/MarketingEvents/style.css +++ /dev/null @@ -1,224 +0,0 @@ -.marketing-events-panel { - min-height: calc(100vh - 84px); - overflow: hidden; -} - -.marketing-events-panel__heading { - display: flex; - align-items: baseline; - gap: var(--sycm-space-sm); - padding: var(--sycm-module-padding) var(--sycm-module-padding) 0; -} - -.marketing-events-panel__heading h2 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); - line-height: var(--sycm-line-height-tight); -} - -.marketing-events-panel__heading span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - line-height: var(--sycm-line-height-normal); -} - -.marketing-events-data-date { - margin-left: auto; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); - white-space: nowrap; -} - -.marketing-events-summary { - display: grid; - grid-template-columns: repeat(4, minmax(110px, 1fr)); - gap: var(--sycm-space-sm); - margin-top: var(--sycm-space-sm); - padding: 0 var(--sycm-module-padding); -} - -.marketing-events-summary .sycm-dashboard-metric { - min-height: 88px; - padding: var(--sycm-space-sm); - border-radius: var(--sycm-radius-lg); - background: var(--sycm-bg-subtle); -} - -.marketing-events-summary .sycm-dashboard-metric__label { - font-size: var(--sycm-font-size-caption); -} - -.marketing-events-summary .sycm-dashboard-metric__value { - font-size: var(--sycm-font-size-metric); -} - -.marketing-events-summary__empty { - grid-column: 1 / -1; - padding: var(--sycm-space-md); - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - text-align: center; -} - -.marketing-events-filter { - display: grid; - grid-template-columns: repeat(3, minmax(180px, 1fr)); - gap: var(--sycm-space-sm) var(--sycm-space-xl); - margin-top: var(--sycm-space-sm); - padding: var(--sycm-module-padding); - background: var(--sycm-bg-subtle); -} - -.marketing-events-filter .ant-form-item { - min-width: 0; - margin-bottom: 0; -} - -.marketing-events-filter .ant-form-item-row { - display: grid; - grid-template-columns: 72px minmax(0, 1fr); - align-items: center; -} - -.marketing-events-filter .ant-form-item-label { - padding: 0 var(--sycm-space-sm) 0 0; - overflow: visible; - text-align: right; -} - -.marketing-events-filter .ant-form-item-label > label { - height: var(--sycm-control-height); - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); - line-height: var(--sycm-control-height); -} - -.marketing-events-filter .ant-form-item-control, -.marketing-events-filter .ant-form-item-control-input, -.marketing-events-filter .ant-form-item-control-input-content { - min-height: var(--sycm-control-height); -} - -.marketing-events-filter__actions { - display: flex; - min-height: var(--sycm-control-height); - align-items: center; - gap: var(--sycm-space-sm); - padding-left: 72px; -} - -.marketing-events-table-toolbar { - display: flex; - min-height: 56px; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-md); - padding: var(--sycm-space-sm) var(--sycm-module-padding); - border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); - overflow-x: auto; -} - -.marketing-events-status-switch { - flex: 0 0 auto; -} - -.marketing-events-child-table { - margin: 0; -} - -.marketing-events-child-table .ant-table { - background: var(--sycm-bg-page-soft, #fafafa); -} - -.marketing-events-table .ant-table-wrapper, -.marketing-events-table .ant-spin-nested-loading, -.marketing-events-table .ant-spin-container { - min-width: 0; -} - -.marketing-events-table .ant-table { - min-width: 0; -} - -.marketing-events-table .ant-table-cell { - vertical-align: middle; -} - -.marketing-events-table .ant-table-thead > tr > th { - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-semibold); - white-space: nowrap; -} - -.marketing-events-table .ant-table-tbody > tr > td { - color: var(--sycm-text-primary); - font-size: var(--sycm-font-size-caption); -} - -.marketing-events-table .ant-table-placeholder .ant-table-cell { - height: 220px; -} - -.marketing-events-thumb { - display: block; - width: 52px; - height: 52px; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-sm); - object-fit: cover; -} - -.marketing-events-thumb.is-empty { - display: inline-flex; - align-items: center; - justify-content: center; - width: 52px; - height: 52px; - background: var(--sycm-bg-subtle); - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -.marketing-events-goods-cell { - display: flex; - min-width: 0; - flex-direction: column; - gap: var(--sycm-space-xxs); - line-height: var(--sycm-line-height-normal); -} - -.marketing-events-goods-name { - overflow: hidden; - color: var(--sycm-text-strong); - font-weight: var(--sycm-font-weight-medium); - text-overflow: ellipsis; - white-space: nowrap; -} - -.marketing-events-goods-id { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -@media (max-width: 767px) { - .marketing-events-panel__heading { - align-items: flex-start; - flex-direction: column; - gap: var(--sycm-space-xxs); - } - - .marketing-events-filter { - grid-template-columns: 1fr; - gap: var(--sycm-space-sm); - } - - .marketing-events-filter__actions { - padding-left: 72px; - } -} diff --git a/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx b/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx deleted file mode 100644 index 78f580b..0000000 --- a/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx +++ /dev/null @@ -1,233 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Alert, Button, Card, Col, DatePicker, Descriptions, Drawer, Dropdown, Input, Row, Select, - Space, Spin, Statistic, Table, Tabs, Tag, Typography, message, -} from 'antd'; -import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import { SycmPage } from '../../components/Common'; -import { getOpsBIncentiveBatches, getOpsBIncentiveBoard, getOpsBIncentiveDaily } from '../../api/planningUpload'; -import { exportTableFile } from '../../utils/tableExport'; -import './opsBIncentiveBoard.css'; - -const { Title, Text } = Typography; -const FALLBACK_BATCH = { key: 'ops-b-20260715-20260721', batchNo: 1, scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 }; -const batchLabel = (item) => { - const mmdd = (value) => dayjs(value).format('MM-DD'); - const cap = `${parseFloat((Number(item.feeCap) * 100).toFixed(1))}%`; - return `第${item.batchNo}批(${mmdd(item.scopeStart)} 至 ${mmdd(item.scopeEnd)} · 拉取 ${mmdd(item.pullDate)} · 费比≤${cap})${item.finalized ? ' · 已定档' : ''}`; -}; - -const number = (value, digits = 2) => value === null || value === undefined || value === '' - ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); -const money = (value) => value === null || value === undefined ? '--' : `¥${number(value)}`; -const percent = (value) => value === null || value === undefined ? '--' : `${(Number(value) * 100).toFixed(2)}%`; -const dateText = (value) => value ? dayjs(value).format('YYYY-MM-DD') : '--'; - -const OpsBIncentiveBoardPanel = () => { - const [batches, setBatches] = useState([]); - const [batch, setBatch] = useState(FALLBACK_BATCH.key); - const [metricEnd, setMetricEnd] = useState(dayjs(FALLBACK_BATCH.pullDate)); - const [filters, setFilters] = useState({ ider: '', shopName: '', spu: '', goodsId: '' }); - const [data, setData] = useState({ rows: [], ownerSummary: [], summary: {} }); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [selected, setSelected] = useState(null); - const [dailyRows, setDailyRows] = useState([]); - const [dailyLoading, setDailyLoading] = useState(false); - - const selectedBatch = useMemo( - () => batches.find((item) => item.key === batch) || FALLBACK_BATCH, - [batches, batch] - ); - - useEffect(() => { - let cancelled = false; - getOpsBIncentiveBatches() - .then((result) => { - const list = result?.data?.data || result?.data || []; - if (cancelled || !Array.isArray(list) || !list.length) return; - setBatches(list); - if (!list.some((item) => item.key === FALLBACK_BATCH.key)) { - setBatch(list[0].key); - setMetricEnd(dayjs(list[0].pullDate)); - } - }) - .catch(() => {}); - return () => { cancelled = true; }; - }, []); - - const query = useMemo(() => ({ - scopeStart: selectedBatch.scopeStart, - scopeEnd: selectedBatch.scopeEnd, - metricEnd: metricEnd?.format('YYYY-MM-DD') || selectedBatch.pullDate, - ...filters, - }), [selectedBatch, metricEnd, filters]); - - const changeBatch = (value) => { - setBatch(value); - const next = batches.find((item) => item.key === value); - if (next) setMetricEnd(dayjs(next.pullDate)); - }; - - const load = useCallback(async () => { - setLoading(true); setError(''); - try { - const result = await getOpsBIncentiveBoard(query); - setData(result?.data || result || { rows: [], ownerSummary: [], summary: {} }); - } catch (requestError) { - setError(requestError?.message || '加载运营中心B看板失败'); - } finally { setLoading(false); } - }, [query]); - - useEffect(() => { load(); }, [load]); - - const openDetail = async (record) => { - setSelected(record); setDailyRows([]); setDailyLoading(true); - try { - const result = await getOpsBIncentiveDaily({ ...query, goodsId: record.goodsId }); - setDailyRows(result?.data?.rows || result?.rows || []); - } catch (requestError) { - message.error(requestError?.message || '加载每日明细失败'); - } finally { setDailyLoading(false); } - }; - - const iderOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.ider).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]); - const shopOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.shopName).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]); - // 分组:0=动销率与费比均达标,1=仅一项达标,2=均不达标;组内按动销率降序。 - const sortedRows = useMemo(() => [...(data.rows || [])].sort((a, b) => { - const group = (row) => (row.bonusQualified ? 0 : (row.feeQualified || row.sellThroughQualified ? 1 : 2)); - const rate = (row) => (row.sellThroughRate30d === null || row.sellThroughRate30d === undefined ? -1 : row.sellThroughRate30d); - return group(a) - group(b) || rate(b) - rate(a) || String(a.goodsId).localeCompare(String(b.goodsId)); - }), [data.rows]); - const num = (value) => (value === null || value === undefined || value === '' ? -Infinity : Number(value)); - const qualifyFilters = [ - { text: '达标', value: 'yes' }, - { text: '未达标', value: 'no' }, - ]; - const qualifyFilter = (field) => ({ - filters: qualifyFilters, - onFilter: (value, row) => (value === 'yes' ? Boolean(row[field]) : !row[field]), - }); - const columns = [ - { title: 'ID负责人', dataIndex: 'ider', fixed: 'left', width: 100 }, - { title: '商品ID', dataIndex: 'goodsId', fixed: 'left', width: 130, render: (value, row) => }, - { 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) => {v} }, - { 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) => {v ? '达标' : '未达标'}, 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) => {v ? '达标' : '未达标'}, 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) => ( - handleExport(scope, key), - }}> - - - ); - - const summary = data.summary || {}; - return ( - <> - - - setFilters((old) => ({ ...old, ider: value || '' }))} style={{ width: 150 }} /> - } placeholder="SPU" value={filters.spu} onChange={(e) => setFilters((old) => ({ ...old, spu: e.target.value }))} style={{ width: 150 }} /> - } placeholder="商品ID" value={filters.goodsId} onChange={(e) => setFilters((old) => ({ ...old, goodsId: e.target.value }))} style={{ width: 170 }} /> - - - - {error ? 重试} /> : null} - - - - - - - -
- - -
- - setSelected(null)}> - {selected ? <>达标 ¥{AWARD} : 未达标 }]} />
number(v, 0) }, { title: '是否动销', dataIndex: 'isActive' }, { title: '真实业绩', dataIndex: 'realPerformance', align: 'right', render: money }, { title: '真实费用', dataIndex: 'realFee', align: 'right', render: money }]} /> : null} - - - ); -}; - -const OpsBIncentiveBoard = () => ( - -
-
- 激励看板 - 按中心切换查看商品激励奖金、费比与动销达成情况 -
-
- }, - ]} - /> -
-); - -const AWARD = 400; -export default OpsBIncentiveBoard; diff --git a/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css b/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css deleted file mode 100644 index 68fa2c8..0000000 --- a/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css +++ /dev/null @@ -1,160 +0,0 @@ -.ops-b-incentive-page { - container-name: ops-b-incentive; - container-type: inline-size; - display: flex; - min-height: calc(100% - 46px); - flex-direction: column; - gap: var(--sycm-page-section-gap); - background: var(--sycm-bg-page-soft); -} - -.ops-b-header { - display: flex; - min-height: 84px; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-lg); - margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; - padding: 14px calc(var(--sycm-page-padding-x) + 2px); - border-bottom: 1px solid var(--sycm-border-base); - background: var(--sycm-bg-container); -} - -.ops-b-header__heading { min-width: 0; } -.ops-b-header h3 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-page); - font-weight: var(--sycm-font-weight-semibold); - line-height: var(--sycm-line-height-tight); -} -.ops-b-header__heading .ant-typography { - display: block; - margin-top: 6px; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} - -.ops-b-tabs .ant-tabs-nav { - margin: 0; - padding: 0 calc(var(--sycm-page-padding-x) + 2px); - border-bottom: 1px solid var(--sycm-border-base); - background: var(--sycm-bg-container); -} -.ops-b-tabs .ant-tabs-tab { - margin: 0; - padding: 12px 4px; - font-size: var(--sycm-font-size-title); -} -.ops-b-tabs .ant-tabs-tab + .ant-tabs-tab { margin-left: 32px; } -.ops-b-tabs .ant-tabs-content-holder, -.ops-b-tabs .ant-tabs-content, -.ops-b-tabs .ant-tabs-tabpane { min-height: 100%; } -.ops-b-tabs .ant-tabs-tabpane { - display: flex; - flex-direction: column; - gap: var(--sycm-page-section-gap); - padding-top: var(--sycm-page-section-gap); -} - -.ops-b-filter-card, -.ops-b-panel, -.ops-b-summary-card { - border: 1px solid var(--sycm-border-card) !important; - border-radius: var(--sycm-radius-lg) !important; - box-shadow: var(--sycm-shadow-card) !important; -} -.ops-b-filter-card { margin-bottom: 0; } -.ops-b-filter-card .ant-card-body { padding: 14px 16px; } -.ops-b-filter-card .ant-space { width: 100%; } -.ops-b-filter-card .ant-space-item { max-width: 100%; } -.ops-b-filter-card .ant-select, -.ops-b-filter-card .ant-picker, -.ops-b-filter-card .ant-input-affix-wrapper { max-width: 100%; } - -.ops-b-summary-row { margin-top: -4px; } -.ops-b-summary-row .ant-col { display: flex; } -.ops-b-summary-card { - position: relative; - width: 100%; - min-height: 98px; - overflow: hidden; -} -.ops-b-summary-card::before { - position: absolute; - inset: 0 auto 0 0; - width: 3px; - background: var(--sycm-color-primary); - content: ''; -} -.ops-b-summary-card.is-green::before { background: var(--sycm-color-success); } -.ops-b-summary-card.is-orange::before { background: var(--sycm-color-orange); } -.ops-b-summary-card.is-gold::before { background: var(--sycm-color-warning); } -.ops-b-summary-card .ant-card-body { - display: flex; - min-height: 98px; - align-items: center; - padding: 16px 18px; -} -.ops-b-summary-card .ant-statistic { width: 100%; } -.ops-b-summary-card .ant-statistic-title { - margin-bottom: 8px; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} -.ops-b-summary-card .ant-statistic-content { - color: var(--sycm-text-strong); - font-family: var(--sycm-font-family-number); - font-size: var(--sycm-font-size-metric); - line-height: 1.1; -} - -.ops-b-panel { margin-bottom: 0; overflow: hidden; } -.ops-b-panel .ant-card-head { - min-height: 48px; - padding: 0 16px; - border-bottom-color: var(--sycm-border-light); -} -.ops-b-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } -.ops-b-panel .ant-card-body { padding: 0 16px 16px; } -.ops-b-panel .ant-table { font-size: var(--sycm-font-size-caption); } -.ops-b-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } -.ops-b-panel .ant-table-placeholder .ant-table-cell { height: 180px; } -.ops-b-id-link { - height: auto; - padding: 0; - font-family: var(--sycm-font-family-number); - font-weight: var(--sycm-font-weight-semibold); -} -.ops-b-daily-table { margin-top: var(--sycm-space-md); } - -.ops-b-detail-drawer .ant-drawer-header { - padding: 16px 20px; - border-bottom-color: var(--sycm-border-light); -} -.ops-b-detail-drawer .ant-drawer-title { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); -} -.ops-b-detail-drawer .ant-drawer-body { padding: 20px; } -.ops-b-detail-drawer .ant-descriptions { margin-bottom: var(--sycm-space-md); } - -@container ops-b-incentive (max-width: 900px) { - .ops-b-filter-card .ant-space-item { flex: 1 1 180px; } -} - -@media (max-width: 767px) { - .ops-b-header { - min-height: 72px; - align-items: flex-start; - flex-direction: column; - gap: var(--sycm-space-sm); - padding-block: 12px; - } - .ops-b-filter-card .ant-select, - .ops-b-filter-card .ant-picker, - .ops-b-filter-card .ant-input-affix-wrapper { width: 100%; } - .ops-b-panel .ant-card-body { padding-inline: 10px; } - .ops-b-detail-drawer .ant-drawer-body { padding: 14px; } -} diff --git a/frontend/src/pages/ProductAnalysis/index.jsx b/frontend/src/pages/ProductAnalysis/index.jsx deleted file mode 100644 index b785632..0000000 --- a/frontend/src/pages/ProductAnalysis/index.jsx +++ /dev/null @@ -1,990 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { - Button, - Checkbox, - DatePicker, - Drawer, - Empty, - Image, - Input, - InputNumber, - Popover, - Segmented, - Select, - Skeleton, - Space, - Table, - Tooltip, - Typography, - message, -} from 'antd'; -import { - ExclamationCircleOutlined, - ReloadOutlined, - RightOutlined, - SearchOutlined, - SettingOutlined, -} from '@ant-design/icons'; -import ReactECharts from 'echarts-for-react'; -import dayjs from 'dayjs'; -import { useSearchParams } from 'react-router-dom'; -import { - getProductAnalysisMeta, - getProductAnalysisProducts, - getProductAnalysisSignalThresholds, - getProductAnalysisTab, - saveProductAnalysisSignalThresholds, -} from '../../api/productAnalysis'; -import { - SycmBusinessPanel, - SycmDashboardMetric, - SycmModuleTabs, - SycmPage, - SycmStatusTag, - SycmToolbar, -} from '../../components/Common'; -import { - SYCM_CHART_PEER_AVG_COLOR, - SYCM_CHART_STORE_COLOR, - sycmEchartsBase, - sycmLineSeriesDefaults, -} from '../../design-system'; -import './style.css'; - -const { Text } = Typography; -const { RangePicker } = DatePicker; - -const DEFAULT_TAB = 'refunds'; -const TAB_ITEMS = [ - { key: 'refunds', label: '退款售后' }, - { key: 'promotion', label: '推广投放' }, - { key: 'activities', label: '活动信息' }, - { key: 'navigator', label: '商品领航员' }, - { key: 'service-issues', label: '体验问题' }, - { key: 'bad-reviews', label: '差评分析' }, -]; -const TAB_KEY_SET = new Set(TAB_ITEMS.map((item) => item.key)); -const RANGE_SHORTCUTS = [ - { key: 'realtime', label: '实时' }, - { key: 'yesterday', label: '昨日' }, - { key: '7d', label: '近7天' }, - { key: '30d', label: '近30天' }, - { key: 'week', label: '本周' }, - { key: 'month', label: '本月' }, -]; -const DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS = Object.freeze({ - ctr: { low: 3, high: 5 }, - conversion: { low: 0.3, high: 0.5 }, -}); -const TRAFFIC_SIGNAL_FILTER_OPTIONS = [ - { value: 'all', label: '全部' }, - { value: 'red', label: 红灯 }, - { value: 'yellow', label: 黄灯 }, - { value: 'green', label: 绿灯 }, -]; - -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 --; - } - - const redReasons = metrics.filter((metric) => metric.signal.tone === 'red'); - - return ( -
- {metrics.length ? ( -
- {metrics.map((metric) => ( - - - - {metric.label} - {formatPercent(metric.value)} - - - ))} -
- ) : null} - {row.signalIsObserving ? ( -
- - - 新品观察期 - 点击率、转化率于第 8 天起展示 - -
- ) : null} - {redReasons.length ? ( - metric.reason).join(';')}> - - {redReasons.map((metric) => metric.reason).join(';')} - - ) : null} -
- ); -}; - -const ProductQualitySignalCell = ({ row }) => { - if (!row.signalQualityStatus) { - return --; - } - return ( -
-
- - - - 品质退款率 - {formatPercent(row.signalQualityRefundRate)} - - -
-
- ); -}; - -const TrafficSignalSettings = ({ value, onChange, onReset, onSave, saving }) => ( -
-
- 经营信号阈值 - 近30天 · 新品观察期7天 -
- {[ - { key: 'ctr', label: '点击率', step: 0.1 }, - { key: 'conversion', label: '转化率', step: 0.1 }, - ].map((metric) => ( -
- {metric.label} - - -
- ))} -
- 品质退款率 -
- 超越30% / 50%同行 - 同行后30% - 同行后10% -
- 仅按主营类目排名判定 -
-
- - -
-
-); - -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) => ( -
- -
- {row.goodsName || '--'} - ID {row.goodsId} · {row.shopName || '--'} -
-
- ), - }, - { 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) => , - }, - { - title: '近30天品质信号(商品领航员)', - key: 'qualitySignal', - width: 190, - render: (_, 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) => {Number(value) === 1 ? '破价' : '正常'}, - }, - ], [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' - ? 查看订单 - : formatTableValue(column.key, value)), - })), [tabData]); - - const serviceIssueOrderColumns = useMemo(() => [ - { - title: '订单号', - dataIndex: 'orderSn', - key: 'orderSn', - width: 210, - render: (value) => {value || '--'}, - }, - { - title: '问题详情', - dataIndex: 'problemDetail', - key: 'problemDetail', - width: 220, - render: (value) => {value || '--'}, - }, - { - title: '问题分类', - dataIndex: 'categoryDescription', - key: 'categoryDescription', - width: 200, - render: (value) => {value || '--'}, - }, - { title: '求助时间', dataIndex: 'seekHelpTime', key: 'seekHelpTime', width: 170 }, - { - title: '体验状态', - dataIndex: 'isNegative', - key: 'isNegative', - width: 110, - align: 'center', - render: (value) => ( - {value ? '负向体验' : '一般问题'} - ), - }, - ], []); - - 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 ( - - - -
- ({ value: shop, label: shop }))} - onChange={(value) => setFilters((current) => ({ ...current, shopName: value || '' }))} - aria-label="店铺" - /> - } - value={filters.keyword} - placeholder="搜索商品ID / SKU / SPU / 商品名" - onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))} - onPressEnter={handleQuery} - /> - setFilters((current) => ({ ...current, onlyActive: event.target.checked }))} - > - 仅看有销售/投放 - -
- - - - -
-
- - 数据区间 {metrics.startDate || '--'} 至 {metrics.endDate || '--'}} - className="product-analysis-overview-panel" - > -
- {dashboardMetrics.map(({ key, ...item }) => )} -
-
- - - - 共 {formatNumber(pagination.total)} 款,点击行查看单品模块 - { - setSignalSettingsOpen(open); - if (open) setSignalThresholdDraft(signalThresholds); - }} - content={( - - )} - > - - - - )} - className="product-analysis-table-panel" - > -
`共 ${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: }} - /> - - - - - - {selectedProduct.goodsName || '未命名商品'} - {selectedProduct.shopName} · 商品ID {selectedProduct.goodsId} · SKU {selectedProduct.skuCode || '--'} - - - ) : '单品业务模块'} - extra={selectedProduct ? {selectedProduct.idOwner || '未分配负责人'} : null} - className="product-analysis-detail-panel" - > - {selectedProduct ? ( - <> - -
- {tabLoading ? : null} - {!tabLoading && tabData?.status === 'empty' ? ( - - ) : null} - {!tabLoading && tabData?.status === 'error' ? ( - - ) : null} - {!tabLoading && tabData?.status === 'ready' ? ( - <> -
- {(tabData.metrics || []).map((item) => ( - - ))} -
-
- {tabData.trend?.length ? ( -
-

{tabData.trendTitle || '区间趋势'}

- -
- ) : null} -
-

{tabData.tableTitle || '历史明细'}

-
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}的具体订单`, - } : {})} - /> - - - - ) : null} - - - ) : ( - - )} - - setServiceIssueDetail(null)} - destroyOnHidden - > -
row.id} - size="small" - columns={serviceIssueOrderColumns} - dataSource={serviceIssueDetail?.orders || []} - pagination={{ pageSize: 20, showSizeChanger: false }} - scroll={{ x: 910 }} - locale={{ emptyText: }} - /> - - - ); -}; - -export default ProductAnalysis; diff --git a/frontend/src/pages/ProductAnalysis/style.css b/frontend/src/pages/ProductAnalysis/style.css deleted file mode 100644 index 15f7ceb..0000000 --- a/frontend/src/pages/ProductAnalysis/style.css +++ /dev/null @@ -1,776 +0,0 @@ -.product-analysis-page { - container-name: product-analysis; - container-type: inline-size; - display: flex; - flex-direction: column; - gap: var(--sycm-page-section-gap); -} - -.product-analysis-filter-panel .sycm-business-panel__body { - padding: 12px 16px; -} - -.product-analysis-filter-panel .sycm-toolbar { - display: grid !important; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center !important; - gap: 12px; -} - -.product-analysis-filter-panel .sycm-toolbar > .ant-space { - margin-left: 0; -} - -.product-analysis-filters { - display: grid; - width: 100%; - min-width: 0; - flex: 1 1 100%; - grid-template-columns: 96px minmax(170px, 210px) max-content 236px minmax(220px, 1fr) auto; - align-items: center; - gap: 8px; -} - -.product-analysis-range-shortcuts { - display: inline-flex; - height: var(--sycm-control-height); - align-items: stretch; - overflow: hidden; - border: 1px solid var(--sycm-border-base); - border-radius: var(--sycm-radius-md); - background: var(--sycm-bg-container); -} - -.product-analysis-range-shortcuts button { - min-width: 46px; - padding: 0 10px; - border: 0; - border-right: 1px solid var(--sycm-border-light); - background: transparent; - color: var(--sycm-text-secondary); - font-family: inherit; - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); - line-height: 30px; - white-space: nowrap; - cursor: pointer; - transition: color var(--sycm-interaction-duration-fast), background var(--sycm-interaction-duration-fast); -} - -.product-analysis-range-shortcuts button:last-child { - border-right: 0; -} - -.product-analysis-range-shortcuts button:hover { - color: var(--sycm-color-primary); - background: var(--sycm-color-primary-bg); -} - -.product-analysis-range-shortcuts button.is-active { - background: var(--sycm-bg-container); - color: var(--sycm-color-primary); - box-shadow: inset 0 -2px 0 var(--sycm-color-primary); -} - -.product-analysis-filters .ant-select, -.product-analysis-filters .ant-input-affix-wrapper { - width: 100%; -} - -.product-analysis-filters > .ant-input-affix-wrapper { - height: var(--sycm-control-height); - min-height: var(--sycm-control-height); - align-items: center; - padding-top: 6px; - padding-bottom: 6px; -} - -.product-analysis-filters > .ant-input-affix-wrapper .ant-input { - height: 18px; - min-height: 0; - padding-top: 0; - padding-bottom: 0; - line-height: 18px; -} - -.product-analysis-filters .ant-checkbox-wrapper { - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - white-space: nowrap; -} - -.product-analysis-filters .product-analysis-active-filter { - display: inline-flex; - height: var(--sycm-control-height); - align-items: center; - margin: 0; - padding: 0 9px; - border: 1px solid var(--sycm-border-light); - border-radius: var(--sycm-radius-md); - background: var(--sycm-bg-subtle); - line-height: var(--sycm-line-height-normal); -} - -.product-analysis-filters .product-analysis-active-filter .ant-checkbox { - top: 2px; -} - -.product-analysis-filters .product-analysis-active-filter > span:last-child { - display: inline-flex; - height: 100%; - align-items: center; - padding-inline-start: 6px; - line-height: var(--sycm-line-height-normal); -} - -.product-analysis-filters .product-analysis-active-filter:hover { - border-color: var(--sycm-color-primary-border); - background: var(--sycm-color-primary-bg); -} - -.product-analysis-metrics, -.product-analysis-tab-metrics { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 12px; -} - -.product-analysis-overview-panel > .sycm-business-panel__body { - padding: 0 4px 4px; -} - -.product-analysis-overview-panel .product-analysis-metrics { - gap: 0; - overflow: hidden; - border-radius: 0 0 var(--sycm-radius-xl) var(--sycm-radius-xl); -} - -.product-analysis-overview-panel .sycm-dashboard-metric { - min-height: 98px; - border: 0; - border-right: 1px solid var(--sycm-border-light); - border-radius: 0; - padding: 14px 16px 13px; - background: var(--sycm-bg-container); -} - -.product-analysis-overview-panel .sycm-dashboard-metric:last-child { - border-right: 0; -} - -.product-analysis-overview-panel .sycm-dashboard-metric__label { - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-regular); -} - -.product-analysis-metrics .sycm-dashboard-metric, -.product-analysis-tab-metrics .sycm-dashboard-metric { - min-height: 96px; -} - -.product-analysis-table-panel > .sycm-business-panel__body { - padding: 0; -} - -.product-analysis-table-panel .ant-table-wrapper { - border-top: 1px solid var(--sycm-border-light); -} - -.product-analysis-table-panel .ant-table-row { - cursor: pointer; -} - -.product-analysis-table-panel .ant-table-row.is-selected > td { - background: var(--sycm-interaction-selected-bg) !important; -} - -.product-analysis-table-panel .ant-table-row.is-selected > td:first-child { - box-shadow: inset 3px 0 0 var(--sycm-color-primary); -} - -.product-analysis-product-cell { - display: flex; - min-width: 0; - align-items: center; - gap: 10px; -} - -.product-analysis-product-cell .ant-image, -.product-analysis-product-cell img { - width: 46px; - height: 46px; - flex: 0 0 46px; - border-radius: var(--sycm-radius-sm); - background: var(--sycm-bg-subtle); - object-fit: cover; -} - -.product-analysis-product-cell > div { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 5px; -} - -.product-analysis-product-cell strong { - overflow: hidden; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); - line-height: 18px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.product-analysis-product-cell span { - overflow: hidden; - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.product-analysis-signal-cell { - display: flex; - min-width: 0; - flex-direction: column; - gap: 5px; -} - -.product-analysis-signal-list { - display: flex; - align-items: center; - gap: 6px; -} - -.product-analysis-signal { - display: inline-grid; - height: 28px; - min-width: 118px; - grid-template-columns: 9px auto 1fr; - align-items: center; - gap: 6px; - padding: 0 8px; - border: 1px solid transparent; - border-radius: var(--sycm-radius-md); - font-size: var(--sycm-font-size-caption); - line-height: 1; - white-space: nowrap; -} - -.product-analysis-signal strong { - justify-self: end; - color: currentColor; - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-semibold); -} - -.product-analysis-signal__lamp { - width: 8px; - height: 8px; - border: 2px solid color-mix(in srgb, currentColor 18%, transparent); - border-radius: 50%; - background: currentColor; - box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 10%, transparent); -} - -.product-analysis-signal.is-red { - border-color: var(--sycm-color-danger-border); - background: var(--sycm-color-danger-bg); - color: var(--sycm-color-danger); -} - -.product-analysis-signal.is-yellow { - border-color: var(--sycm-color-warning-border); - background: var(--sycm-color-warning-bg); - color: var(--sycm-color-warning); -} - -.product-analysis-signal.is-green { - border-color: var(--sycm-color-success-border); - background: var(--sycm-color-success-bg); - color: var(--sycm-color-success); -} - -.product-analysis-signal-reason { - display: flex; - width: 100%; - min-width: 0; - align-items: flex-start; - gap: 5px; - color: var(--sycm-color-danger); - font-size: 11px; - line-height: 15px; -} - -.product-analysis-signal-reason .anticon { - flex: 0 0 auto; - margin-top: 1px; -} - -.product-analysis-signal-reason > span:last-child { - min-width: 0; - overflow-wrap: anywhere; - white-space: normal; -} - -.product-analysis-signal-observing { - display: flex; - width: max-content; - min-width: 190px; - align-items: center; - gap: 9px; - padding: 6px 10px; - border: 1px solid var(--sycm-color-primary-border); - border-radius: var(--sycm-radius-md); - background: var(--sycm-color-primary-bg); - color: var(--sycm-color-primary); -} - -.product-analysis-signal-observing__dot { - width: 9px; - height: 9px; - flex: 0 0 auto; - border-radius: 50%; - background: var(--sycm-color-primary); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--sycm-color-primary) 12%, transparent); -} - -.product-analysis-signal-observing > span:last-child { - display: flex; - flex-direction: column; - gap: 2px; -} - -.product-analysis-signal-observing strong { - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-semibold); - line-height: 15px; -} - -.product-analysis-signal-observing small { - color: var(--sycm-text-secondary); - font-size: 11px; - line-height: 14px; -} - -.product-analysis-signal-unavailable { - color: var(--sycm-text-tertiary); -} - -.product-analysis-signal-filter { - flex: 0 0 auto; -} - -.product-analysis-signal-filter .ant-segmented-item-label { - min-width: 48px; - padding-inline: 8px; -} - -.product-analysis-signal-filter__label { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - white-space: nowrap; -} - -.product-analysis-signal-filter__label i { - width: 7px; - height: 7px; - flex: 0 0 7px; - border-radius: 50%; -} - -.product-analysis-signal-filter__label.is-red i { - background: var(--sycm-color-danger); - box-shadow: 0 0 0 3px var(--sycm-color-danger-bg); -} - -.product-analysis-signal-filter__label.is-yellow i { - background: var(--sycm-color-warning); - box-shadow: 0 0 0 3px var(--sycm-color-warning-bg); -} - -.product-analysis-signal-filter__label.is-green i { - background: var(--sycm-color-success); - box-shadow: 0 0 0 3px var(--sycm-color-success-bg); -} - -.product-analysis-signal-settings { - width: 390px; -} - -.product-analysis-signal-settings__header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - padding: 2px 2px 12px; - border-bottom: 1px solid var(--sycm-border-light); -} - -.product-analysis-signal-settings__header strong { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-body); - font-weight: var(--sycm-font-weight-semibold); -} - -.product-analysis-signal-settings__header span { - color: var(--sycm-text-tertiary); - font-size: 11px; -} - -.product-analysis-signal-setting { - display: grid; - grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1fr); - align-items: end; - gap: 10px; - padding: 13px 2px; - border-bottom: 1px solid var(--sycm-border-light); -} - -.product-analysis-signal-setting > strong { - align-self: center; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-semibold); -} - -.product-analysis-signal-setting label { - display: flex; - min-width: 0; - flex-direction: column; - gap: 6px; -} - -.product-analysis-signal-setting label > span { - font-size: 11px; - font-weight: var(--sycm-font-weight-medium); -} - -.product-analysis-signal-setting label > span.is-red { - color: var(--sycm-color-danger); -} - -.product-analysis-signal-setting label > span.is-green { - color: var(--sycm-color-success); -} - -.product-analysis-signal-setting .ant-input-number { - width: 100%; -} - -.product-analysis-threshold-input { - display: grid; - min-width: 0; - grid-template-columns: minmax(0, 1fr) 28px; -} - -.product-analysis-threshold-input .ant-input-number { - border-start-end-radius: 0; - border-end-end-radius: 0; -} - -.product-analysis-threshold-input > span { - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--sycm-border-base); - border-left: 0; - border-start-end-radius: var(--sycm-radius-sm); - border-end-end-radius: var(--sycm-radius-sm); - background: var(--sycm-bg-subtle); - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} - -.product-analysis-quality-rule { - display: grid; - grid-template-columns: 76px minmax(0, 1fr); - gap: 8px 10px; - align-items: center; - padding: 13px 2px; - border-bottom: 1px solid var(--sycm-border-light); -} - -.product-analysis-quality-rule > strong { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-semibold); -} - -.product-analysis-quality-rule > div { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.product-analysis-quality-rule span { - padding: 3px 6px; - border: 1px solid currentColor; - border-radius: var(--sycm-radius-sm); - font-size: 11px; - line-height: 15px; -} - -.product-analysis-quality-rule .is-green { - color: var(--sycm-color-success); -} - -.product-analysis-quality-rule .is-yellow { - color: var(--sycm-color-warning); -} - -.product-analysis-quality-rule .is-red { - color: var(--sycm-color-danger); -} - -.product-analysis-quality-rule small { - grid-column: 2; - color: var(--sycm-text-tertiary); - font-size: 11px; -} - -.product-analysis-signal-settings__footer { - display: flex; - justify-content: flex-end; - gap: 8px; - padding-top: 12px; -} - -.product-analysis-detail-panel > .sycm-business-panel__body { - padding: 0; -} - -.product-analysis-detail-panel > .sycm-business-panel__header { - min-height: 64px; - padding-top: 8px; - padding-bottom: 8px; -} - -.product-analysis-detail-panel .sycm-business-panel__heading, -.product-analysis-detail-panel .sycm-business-panel__copy, -.product-analysis-detail-panel .sycm-business-panel__title { - min-width: 0; - width: 100%; -} - -.product-analysis-selected-product { - display: flex; - min-width: 0; - align-items: center; - gap: 10px; -} - -.product-analysis-selected-product .ant-image, -.product-analysis-selected-product img { - width: 40px; - height: 40px; - flex: 0 0 40px; - border-radius: var(--sycm-radius-sm); - background: var(--sycm-bg-subtle); - object-fit: cover; -} - -.product-analysis-selected-product__copy { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 3px; -} - -.product-analysis-selected-product__copy strong, -.product-analysis-selected-product__copy > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.product-analysis-selected-product__copy strong { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-body); - font-weight: var(--sycm-font-weight-semibold); - line-height: 20px; -} - -.product-analysis-selected-product__copy > span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-regular); - line-height: 16px; -} - -.product-analysis-detail-panel .sycm-module-tabs { - border-top: 1px solid var(--sycm-border-light); - border-bottom: 1px solid var(--sycm-border-light); - border-radius: 0; - box-shadow: none; -} - -.product-analysis-tab-content { - min-height: 280px; - padding: 16px; -} - -.product-analysis-tab-metrics { - grid-template-columns: repeat(4, minmax(0, 1fr)); - margin-bottom: 16px; -} - -.product-analysis-tab-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 16px; -} - -.product-analysis-tab-grid.is-table-only { - grid-template-columns: 1fr; -} - -.product-analysis-trend-block, -.product-analysis-history-block { - min-width: 0; - overflow: hidden; - border: 1px solid var(--sycm-border-base); - border-radius: var(--sycm-radius-lg); - background: var(--sycm-bg-container); -} - -.product-analysis-trend-block { - padding: 14px 16px 8px; -} - -.product-analysis-history-block h3 { - padding: 14px 16px 12px; - border-bottom: 1px solid var(--sycm-border-light); -} - -.product-analysis-trend-block h3, -.product-analysis-history-block h3 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); - line-height: 22px; -} - -.product-analysis-history-block .ant-table-wrapper { - width: 100%; -} - -.product-analysis-history-block .product-analysis-issue-row { - cursor: pointer; -} - -.product-analysis-history-block .product-analysis-issue-row:focus-visible > td { - background: var(--sycm-color-primary-soft); - outline: 1px solid var(--sycm-color-primary-border); - outline-offset: -1px; -} - -.product-analysis-view-orders { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--sycm-color-primary); - font-weight: var(--sycm-font-weight-medium); -} - -.product-analysis-order-detail { - display: block; - white-space: normal; - word-break: break-word; - line-height: 20px; -} - -@container product-analysis (max-width: 1180px) { - .product-analysis-filters { - grid-template-columns: 100px minmax(180px, 1fr) minmax(220px, 1fr); - } - - .product-analysis-range-shortcuts, - .product-analysis-filters .ant-picker, - .product-analysis-filters .ant-checkbox-wrapper { - grid-column: 1 / -1; - } - - .product-analysis-range-shortcuts { - width: max-content; - max-width: 100%; - overflow-x: auto; - } - - .product-analysis-metrics { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .product-analysis-overview-panel .sycm-dashboard-metric:nth-child(3n) { - border-right: 0; - } - - .product-analysis-tab-grid { - grid-template-columns: 1fr; - } -} - -@container product-analysis (max-width: 1500px) { - .product-analysis-filter-panel .sycm-toolbar { - grid-template-columns: minmax(0, 1fr); - } - - .product-analysis-filter-panel .sycm-toolbar > .ant-space { - justify-self: end; - } -} - -@container product-analysis (max-width: 760px) { - .product-analysis-filters { - grid-template-columns: 1fr 1fr; - } - - .product-analysis-range-shortcuts, - .product-analysis-filters .ant-picker, - .product-analysis-filters .ant-input-affix-wrapper, - .product-analysis-filters .ant-checkbox-wrapper { - grid-column: 1 / -1; - } - - .product-analysis-metrics, - .product-analysis-tab-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .product-analysis-overview-panel .sycm-dashboard-metric:nth-child(3n) { - border-right: 1px solid var(--sycm-border-light); - } - - .product-analysis-overview-panel .sycm-dashboard-metric:nth-child(2n) { - border-right: 0; - } -} - -@container product-analysis (max-width: 480px) { - .product-analysis-filters, - .product-analysis-metrics, - .product-analysis-tab-metrics { - grid-template-columns: 1fr; - } - - .product-analysis-filters > * { - grid-column: 1; - } - - .product-analysis-overview-panel .sycm-dashboard-metric { - border-right: 0; - border-bottom: 1px solid var(--sycm-border-light); - } - - .product-analysis-overview-panel .sycm-dashboard-metric:last-child { - border-bottom: 0; - } -} diff --git a/frontend/src/pages/ProductMonitoring/index.jsx b/frontend/src/pages/ProductMonitoring/index.jsx deleted file mode 100644 index c99d953..0000000 --- a/frontend/src/pages/ProductMonitoring/index.jsx +++ /dev/null @@ -1,340 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Alert, Button, Card, Col, Drawer, Dropdown, Image, Input, Row, Select, - Segmented, Space, Spin, Statistic, Table, Tabs, Tag, Tooltip, Typography, message, -} from 'antd'; -import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; -import { SycmPage } from '../../components/Common'; -import { - getProductMonitoringList, - getProductMonitoringFilters, - getProductMonitoringOperators, -} from '../../api/productMonitoring'; -import { exportTableFile } from '../../utils/tableExport'; -import './style.css'; - -const { Title, Text } = Typography; - -const number = (value, digits = 2) => value === null || value === undefined || value === '' - ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); -const int = (value) => number(value, 0); -const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); - -const COMPANY_TAG_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' }; -const DEPARTMENT_TAG_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' }; -const STATUS_TABS = [ - { key: '销售', label: '销售' }, - { key: '停产', label: '停产' }, - { key: '退市', label: '退市' }, - { key: '__all__', label: '全部' }, -]; - -const toOptions = (list = []) => (Array.isArray(list) ? list : []) - .filter(Boolean) - .map((value) => ({ value, label: value })); - -const ProductMonitoringPanel = () => { - const [status, setStatus] = useState('销售'); - const [filters, setFilters] = useState({ - category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '', - purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '', - }); - const [filterOptions, setFilterOptions] = useState({ categories: [], suppliers: [], warehouses: [], purchasers: [] }); - const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, latestDate: null }); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(50); - const [sorter, setSorter] = useState({ field: 'companySales7d', order: 'DESC' }); - const [selected, setSelected] = useState(null); - const [operators, setOperators] = useState([]); - const [operatorsLoading, setOperatorsLoading] = useState(false); - - useEffect(() => { - let cancelled = false; - getProductMonitoringFilters() - .then((result) => { - const payload = result?.data || {}; - if (cancelled) return; - setFilterOptions({ - categories: payload.categories || [], - suppliers: payload.suppliers || [], - warehouses: payload.warehouses || [], - purchasers: payload.purchasers || [], - }); - }) - .catch(() => {}); - return () => { cancelled = true; }; - }, []); - - const query = useMemo(() => { - const params = { - page, - pageSize, - sortField: sorter.field, - sortOrder: sorter.order, - }; - if (status === '__all__') params.includeAll = '1'; - else params.saleStatus = status; - - Object.entries(filters).forEach(([key, value]) => { - if (Array.isArray(value)) { - if (value.length) params[key] = value.join(','); - } else if (value) { - params[key] = value; - } - }); - return params; - }, [page, pageSize, sorter, status, filters]); - - const load = useCallback(async () => { - setLoading(true); - setError(''); - try { - const result = await getProductMonitoringList(query); - setData(result?.data || { rows: [], total: 0, summary: {} }); - } catch (requestError) { - setError(requestError?.response?.data?.error || requestError?.message || '加载商品监控数据失败'); - } finally { - setLoading(false); - } - }, [query]); - - useEffect(() => { load(); }, [load]); - - const changeStatus = (key) => { - setStatus(key); - setPage(1); - }; - const changeFilter = (key) => (value) => { - setFilters((old) => ({ ...old, [key]: value || '' })); - setPage(1); - }; - const changeMultiFilter = (key) => (value) => { - setFilters((old) => ({ ...old, [key]: value || [] })); - setPage(1); - }; - const resetFilters = () => { - setFilters({ - category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '', - purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '', - }); - setPage(1); - }; - - const openDetail = async (record) => { - setSelected(record); - setOperators([]); - setOperatorsLoading(true); - try { - const result = await getProductMonitoringOperators(record.skuCode || record.skuId); - setOperators(result?.data?.operators || []); - } catch (requestError) { - message.error(requestError?.response?.data?.error || requestError?.message || '加载运营明细失败'); - } finally { - setOperatorsLoading(false); - } - }; - - const columns = useMemo(() => [ - { title: '图片', dataIndex: 'imageUrl', width: 64, fixed: 'left', exportable: false, render: (value) => (value ? : --) }, - { 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) => }, - { title: '条码', dataIndex: 'barcode', width: 150, ellipsis: true }, - { title: '产品名称', dataIndex: 'productName', width: 240, ellipsis: true, sorter: true, render: (value) => {text(value)} }, - { 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) => {text(v)} }, - { 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) => {text(v)} }, - { title: '可使用天数', dataIndex: 'stockDays', width: 110, align: 'right', sorter: true, render: (v) => number(v, 1) }, - { title: '公司商品标签', dataIndex: 'companyTag', width: 110, sorter: true, render: (v) => (v ? {v} : --) }, - { title: '部门', dataIndex: 'department', width: 110, ellipsis: true, sorter: true, render: text }, - { title: '部门的标签', dataIndex: 'departmentTag', width: 110, sorter: true, render: (v) => (v ? {v} : --) }, - { 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) => {text(v)} }, - { 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 ( - <> - -
- 销售状态 - ({ label: item.label, value: item.key }))} - /> -
- - - - ({ value, label: value }))} - value={filters.departmentTag} onChange={changeMultiFilter('departmentTag')} style={{ minWidth: 160 }} - /> - } placeholder="SPU" value={filters.spu} onChange={(e) => changeFilter('spu')(e.target.value)} style={{ width: 150 }} /> - } placeholder="SKU / 条码" value={filters.sku} onChange={(e) => changeFilter('sku')(e.target.value)} style={{ width: 160 }} /> - } placeholder="产品名称" value={filters.productName} onChange={(e) => changeFilter('productName')(e.target.value)} style={{ width: 180 }} /> - } placeholder="部门SKU负责人" value={filters.departmentOwner} onChange={(e) => changeFilter('departmentOwner')(e.target.value)} style={{ width: 160 }} /> - - - -
- - {error ? 重试} /> : null} - - -
- - - - - - - 商品明细({data.total || 0} 条) - {data.latestDate ? 统计日 {String(data.latestDate).slice(0, 10)} : null} - - )} - extra={( - handleExport(key), - }}> - - - )} - > - -
{ - 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))' }} - /> - - - - setSelected(null)} - > - {selected ? ( - <> - - 产品名称:{text(selected.productName)} - 部门:{text(selected.department)} - 部门标签:{text(selected.departmentTag)} - 部门SKU负责人:{text(selected.departmentOwner)} - -
- - ) : null} - - - ); -}; - -const ProductMonitoring = () => ( - -
-
- 商品监控 - 全公司商品档案、库存、标签分级与部门运营指标;点击 SKU 可下钻查看部门运营明细 -
-
- }]} - /> -
-); - -export default ProductMonitoring; diff --git a/frontend/src/pages/ProductMonitoring/style.css b/frontend/src/pages/ProductMonitoring/style.css deleted file mode 100644 index 30fc2f9..0000000 --- a/frontend/src/pages/ProductMonitoring/style.css +++ /dev/null @@ -1,167 +0,0 @@ -.pm-page { - container-name: product-monitoring; - container-type: inline-size; - display: flex; - min-height: calc(100% - 46px); - flex-direction: column; - gap: var(--sycm-page-section-gap); - background: var(--sycm-bg-page-soft); -} - -.pm-header { - display: flex; - min-height: 84px; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-lg); - margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; - padding: 14px calc(var(--sycm-page-padding-x) + 2px); - border-bottom: 1px solid var(--sycm-border-base); - background: var(--sycm-bg-container); -} -.pm-header__heading { min-width: 0; } -.pm-header h3 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-page); - font-weight: var(--sycm-font-weight-semibold); - line-height: var(--sycm-line-height-tight); -} -.pm-header__heading .ant-typography { - display: block; - margin-top: 6px; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} - -.pm-tabs .ant-tabs-nav { - margin: 0; - padding: 0 calc(var(--sycm-page-padding-x) + 2px); - border-bottom: 1px solid var(--sycm-border-base); - background: var(--sycm-bg-container); -} -.pm-tabs .ant-tabs-tab { - margin: 0; - padding: 12px 4px; - font-size: var(--sycm-font-size-title); -} -.pm-tabs .ant-tabs-content-holder, -.pm-tabs .ant-tabs-content, -.pm-tabs .ant-tabs-tabpane { min-height: 100%; } -.pm-tabs .ant-tabs-tabpane { - display: flex; - flex-direction: column; - gap: var(--sycm-page-section-gap); - padding-top: var(--sycm-page-section-gap); -} - -.pm-filter-card, -.pm-panel, -.pm-summary-card { - border: 1px solid var(--sycm-border-card) !important; - border-radius: var(--sycm-radius-lg) !important; - box-shadow: var(--sycm-shadow-card) !important; -} -.pm-filter-card { margin-bottom: 0; } -.pm-filter-card .ant-card-body { padding: 14px 16px; } -.pm-filter-card .ant-space { width: 100%; } -.pm-filter-card .ant-space-item { max-width: 100%; } -.pm-filter-card .ant-select, -.pm-filter-card .ant-input-affix-wrapper { max-width: 100%; } - -.pm-status-bar { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 12px; - flex-wrap: wrap; -} - -.pm-summary-row { margin-top: -4px; } -.pm-summary-row .ant-col { display: flex; } -.pm-summary-card { - position: relative; - width: 100%; - min-height: 98px; - overflow: hidden; -} -.pm-summary-card::before { - position: absolute; - inset: 0 auto 0 0; - width: 3px; - background: var(--sycm-color-primary); - content: ''; -} -.pm-summary-card.is-red::before { background: var(--sycm-color-danger); } -.pm-summary-card.is-orange::before { background: var(--sycm-color-orange); } -.pm-summary-card.is-green::before { background: var(--sycm-color-success); } -.pm-summary-card .ant-card-body { - display: flex; - min-height: 98px; - align-items: center; - padding: 16px 18px; -} -.pm-summary-card .ant-statistic { width: 100%; } -.pm-summary-card .ant-statistic-title { - margin-bottom: 8px; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} -.pm-summary-card .ant-statistic-content { - color: var(--sycm-text-strong); - font-family: var(--sycm-font-family-number); - font-size: var(--sycm-font-size-metric); - line-height: 1.1; -} -.pm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); } - -.pm-panel { margin-bottom: 0; overflow: hidden; } -.pm-panel .ant-card-head { - min-height: 48px; - padding: 0 16px; - border-bottom-color: var(--sycm-border-light); -} -.pm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } -.pm-panel .ant-card-body { padding: 0 16px 16px; } -.pm-panel .ant-table { font-size: var(--sycm-font-size-caption); } -.pm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } -.pm-panel .ant-table-placeholder .ant-table-cell { height: 180px; } -.pm-panel__date { font-size: var(--sycm-font-size-caption); } -.pm-id-link { - height: auto; - padding: 0; - font-family: var(--sycm-font-family-number); - font-weight: var(--sycm-font-weight-semibold); -} -.pm-muted { color: var(--sycm-text-tertiary); } -.pm-daily-table { margin-top: var(--sycm-space-md); } -.pm-detail-meta { margin-bottom: var(--sycm-space-sm); } - -.pm-detail-drawer .ant-drawer-header { - padding: 16px 20px; - border-bottom-color: var(--sycm-border-light); -} -.pm-detail-drawer .ant-drawer-title { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); -} -.pm-detail-drawer .ant-drawer-body { padding: 20px; } - -@container product-monitoring (max-width: 900px) { - .pm-filter-card .ant-space-item { flex: 1 1 180px; } -} - -@media (max-width: 767px) { - .pm-header { - min-height: 72px; - align-items: flex-start; - flex-direction: column; - gap: var(--sycm-space-sm); - padding-block: 12px; - } - .pm-filter-card .ant-select, - .pm-filter-card .ant-input-affix-wrapper { width: 100%; } - .pm-panel .ant-card-body { padding-inline: 10px; } - .pm-detail-drawer .ant-drawer-body { padding: 14px; } -} diff --git a/frontend/src/pages/PromotionModule/index.jsx b/frontend/src/pages/PromotionModule/index.jsx deleted file mode 100644 index c2f74a8..0000000 --- a/frontend/src/pages/PromotionModule/index.jsx +++ /dev/null @@ -1,610 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Button, - Checkbox, - DatePicker, - Drawer, - Input, - Select, - Space, - Spin, - Table, - Tabs, - Tooltip, -} from 'antd'; -import { - PlusOutlined, - ReloadOutlined, - SearchOutlined, - SettingOutlined, -} from '@ant-design/icons'; -import { - SycmBusinessPanel, - SycmDashboardMetric, - SycmPage, -} from '../../components/Common'; -import { - getPromotionModuleList, - getPromotionModuleSummary, - getPromotionModuleShops, -} from '../../api/promotionModule'; -import './style.css'; - -const { RangePicker } = DatePicker; - -const PROMOTION_TYPES = [ - { key: 'stable-cost', label: '稳定成本推广' }, - { key: 'full-store', label: '全店托管' }, -]; - -const DASHBOARD_METRICS = [ - { key: 'orderSpend', label: '成交花费(元)' }, - { key: 'gmv', label: '交易额(元)' }, - { key: 'roi', label: '实际投产比' }, - { key: 'spend', label: '总花费(元)' }, - { key: 'netGmv', label: '净交易额(元)' }, - { key: 'netRoi', label: '净实际投产比' }, - { key: 'netOrderNum', label: '净成交笔数' }, - { key: 'costPerNetOrder', label: '每笔净成交花费(元)' }, -]; - -const PERIOD_OPTIONS = [ - { label: '实时', value: 'realtime' }, - { label: '昨日', value: 'yesterday' }, - { label: '近7天', value: 'last7d' }, - { label: '近30天', value: 'last30d' }, - { label: '本周', value: 'week' }, - { label: '本月', value: 'month' }, -]; - -function fmtMoney(value) { - const n = Number(value); - if (!Number.isFinite(n)) return '-'; - return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -} - -function fmtRate(value) { - const n = Number(value); - if (!Number.isFinite(n)) return '-'; - return `${(n * 100).toFixed(2)}%`; -} - -function fmtNum(value) { - const n = Number(value); - if (!Number.isFinite(n)) return '-'; - return n.toLocaleString('zh-CN'); -} - -function fmtPlain(value) { - const n = Number(value); - if (!Number.isFinite(n)) return '-'; - return n.toFixed(2); -} - -const PromotionModule = () => { - const [promotionType, setPromotionType] = useState('stable-cost'); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [keyword, setKeyword] = useState(''); - const [settingsOpen, setSettingsOpen] = useState(false); - const [addProductOpen, setAddProductOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [dataSource, setDataSource] = useState([]); - const [total, setTotal] = useState(0); - const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); - const [sorter, setSorter] = useState({ field: 'orderSpend', order: 'descend' }); - const [summary, setSummary] = useState(null); - const [summaryLoading, setSummaryLoading] = useState(false); - const [shops, setShops] = useState([]); - const [selectedShop, setSelectedShop] = useState(''); - const [period, setPeriod] = useState('last7d'); - const [customDateRange, setCustomDateRange] = useState(null); - const [filterDateLabel, setFilterDateLabel] = useState(''); - - const fetchShops = useCallback(async () => { - try { - const res = await getPromotionModuleShops(); - if (res?.data) setShops(res.data); - } catch { /* 忽略 */ } - }, []); - - useEffect(() => { - fetchShops(); - }, [fetchShops]); - - const buildParams = useCallback(() => { - const p = {}; - if (period === 'custom' && customDateRange?.length === 2) { - p.period = 'custom'; - p.start = customDateRange[0].format('YYYY-MM-DD'); - p.end = customDateRange[1].format('YYYY-MM-DD'); - } else { - p.period = period; - } - if (selectedShop) p.shopName = selectedShop; - if (keyword.trim()) p.keyword = keyword.trim(); - return p; - }, [period, customDateRange, selectedShop, keyword]); - - const fetchSummary = useCallback(async () => { - setSummaryLoading(true); - try { - const params = buildParams(); - const res = await getPromotionModuleSummary(params); - if (res?.data) { - setSummary(res.data); - setFilterDateLabel(res.data.filterDate || ''); - } - } catch { - setSummary(null); - } finally { - setSummaryLoading(false); - } - }, [buildParams]); - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const params = { - ...buildParams(), - page: pagination.current, - pageSize: pagination.pageSize, - sortBy: sorter.field, - sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC', - }; - const result = await getPromotionModuleList(params); - const data = result?.data; - if (data) { - setDataSource(data.list || []); - setTotal(data.total || 0); - } - } catch { - setDataSource([]); - setTotal(0); - } finally { - setLoading(false); - } - }, [buildParams, pagination, sorter]); - - useEffect(() => { - fetchSummary(); - fetchData(); - }, [fetchSummary, fetchData]); - - const handleTableChange = (pag, _filters, sort) => { - setPagination({ current: pag.current, pageSize: pag.pageSize }); - if (sort.field) { - setSorter({ field: sort.field, order: sort.order || 'descend' }); - } - }; - - const handleSearch = () => { - setPagination((prev) => ({ ...prev, current: 1 })); - }; - - const handlePeriodChange = (value) => { - setPeriod(value); - setCustomDateRange(null); - setPagination((prev) => ({ ...prev, current: 1 })); - }; - - const handleShopChange = (value) => { - setSelectedShop(value); - setPagination((prev) => ({ ...prev, current: 1 })); - }; - - const summaryMetrics = useMemo(() => { - if (!summary) return DASHBOARD_METRICS.map((m) => ({ ...m, value: '--' })); - const changes = summary.changes || {}; - return DASHBOARD_METRICS.map((m) => { - const raw = summary[m.key]; - let formatted = '--'; - if (raw != null && raw !== '-') { - if (['orderSpend', 'gmv', 'spend', 'netGmv', 'costPerNetOrder'].includes(m.key)) { - formatted = fmtMoney(raw); - } else if (m.key === 'netOrderNum') { - formatted = fmtNum(raw); - } else { - formatted = fmtPlain(raw); - } - } - // 环比:与上一等长周期对比 - const change = changes[m.key]; - let trend; - let trendTone; - if (Number.isFinite(change)) { - const percent = Math.abs(change * 100).toFixed(1); - if (change > 0) { - trend = `↑ ${percent}%`; - trendTone = 'up'; - } else if (change < 0) { - trend = `↓ ${percent}%`; - trendTone = 'down'; - } else { - trend = '持平'; - } - } - return { ...m, value: formatted, trend, trendTone }; - }); - }, [summary]); - - const baseColumns = [ - { - title: '推广商品', - key: 'product', - width: 280, - fixed: 'left', - render: (_, record) => ( -
- {record.thumbUrl ? ( - {record.goodsName - ) : ( - 暂无 - )} -
- {record.goodsName || '未命名商品'} - 商品 ID:{record.goodsId || '-'} - 店铺:{record.shopName || '-'} -
-
- ), - }, - { - 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 ( - - -
-
-

推广数据

- 包含稳定成本推广和全店托管数据 -
-
- 统计时间:{filterDateLabel || '--'} - {summary?.previousRange ? ( - 上一周期:{summary.previousRange} - ) : null} - -
-
- -
- {summaryMetrics.map((item) => ( - - ))} -
-
-
- - - - -
- - setKeyword(e.target.value)} - onPressEnter={handleSearch} - suffix={} - /> - - - -
- -
- 已选 {selectedRowKeys.length} 项 - 共 {total} 条推广记录 -
- - -
`共 ${t} 条`, - }} - scroll={{ x: 3000 }} - locale={{ emptyText: loading ? '加载中...' : '暂无推广数据' }} - size="small" - /> - - - - setSettingsOpen(false)} - > -
指标设置功能开发中
-
- - setAddProductOpen(false)} - > - -
- 暂无可添加商品 -
-
- - ); -}; - -export default PromotionModule; diff --git a/frontend/src/pages/PromotionModule/style.css b/frontend/src/pages/PromotionModule/style.css deleted file mode 100644 index bfa4e74..0000000 --- a/frontend/src/pages/PromotionModule/style.css +++ /dev/null @@ -1,214 +0,0 @@ -.promotion-module-page { - display: flex; - flex-direction: column; - gap: var(--sycm-page-section-gap); -} - -.promotion-dashboard, -.promotion-workspace { - overflow: hidden; -} - -.promotion-dashboard { - padding: var(--sycm-module-padding); -} - -.promotion-dashboard__header, -.promotion-actionbar, -.promotion-filterbar, -.promotion-selectionbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-md); -} - -.promotion-dashboard__header > div:first-child { - display: flex; - min-width: 0; - align-items: baseline; - gap: var(--sycm-space-sm); -} - -.promotion-dashboard__header h2 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); -} - -.promotion-dashboard__header span, -.promotion-dashboard__tools, -.promotion-filterbar > span, -.promotion-selectionbar { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); -} - -.promotion-dashboard__tools { - display: flex; - flex: 0 0 auto; - align-items: center; - gap: var(--sycm-space-sm); -} - -.promotion-dashboard__metrics { - display: grid; - grid-template-columns: repeat(8, minmax(110px, 1fr)); - gap: var(--sycm-space-sm); - margin-top: var(--sycm-space-sm); -} - -.promotion-dashboard__metrics .sycm-dashboard-metric { - min-height: 96px; - padding: var(--sycm-space-sm); - border-radius: var(--sycm-radius-lg); - background: var(--sycm-bg-subtle); -} - -.promotion-dashboard__metrics .sycm-dashboard-metric__label { - font-size: var(--sycm-font-size-caption); -} - -.promotion-dashboard__metrics .sycm-dashboard-metric__value { - font-size: var(--sycm-font-size-metric); -} - -.promotion-type-tabs .ant-tabs-nav { - min-height: var(--sycm-module-header-min-height); - margin: 0 !important; - padding: 0 var(--sycm-module-padding); - background: var(--sycm-bg-subtle); -} - -.promotion-type-tabs .ant-tabs-content-holder { - display: none; -} - -.promotion-actionbar { - min-height: calc(var(--sycm-control-height) + var(--sycm-space-lg)); - padding: var(--sycm-space-sm) var(--sycm-module-padding); -} - -.promotion-actionbar > div { - display: flex; - gap: var(--sycm-space-sm); -} - -.promotion-filterbar { - justify-content: flex-start; - padding: var(--sycm-space-sm) var(--sycm-module-padding); - border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); - background: var(--sycm-bg-subtle); -} - -.promotion-filterbar .ant-space { - flex-wrap: nowrap; -} - -.promotion-filterbar__shop { - width: 160px; -} - -.promotion-filterbar__search { - width: 240px; -} - -.promotion-selectionbar { - min-height: var(--sycm-module-header-min-height); - justify-content: flex-start; - padding: 0 var(--sycm-module-padding); -} - -.promotion-table .ant-table-placeholder .ant-table-cell { - height: 320px; -} - -.promotion-product-cell { - display: flex; - min-width: 0; - align-items: flex-start; - gap: var(--sycm-space-sm); -} - -.promotion-product-cell > img, -.promotion-product-cell__image { - display: inline-flex; - width: 52px; - height: 52px; - flex: 0 0 52px; - align-items: center; - justify-content: center; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-sm); - background: var(--sycm-bg-subtle); - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); - object-fit: cover; -} - -.promotion-product-cell > div { - min-width: 0; -} - -.promotion-product-cell strong, -.promotion-product-cell span { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.promotion-product-cell strong { - max-width: 180px; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); -} - -.promotion-product-cell span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); -} - -.promotion-budget-cell { - display: flex; - flex-direction: column; - gap: var(--sycm-space-xxs); - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} - -.promotion-metric-settings { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--sycm-space-sm); -} - -.promotion-metric-settings .ant-checkbox-wrapper { - margin-inline-start: 0; - font-size: var(--sycm-font-size-caption); -} - -.promotion-add-product-empty { - margin-top: 96px; -} - -@media (max-width: 1180px) { - .promotion-dashboard__metrics { - grid-template-columns: repeat(4, minmax(110px, 1fr)); - } - - .promotion-filterbar { - flex-wrap: wrap; - } - - .promotion-filterbar .ant-input-search { - width: 100%; - } - - .promotion-filterbar > span { - margin-left: 0; - } -} diff --git a/frontend/src/pages/Settings/DepartmentTagSettings.jsx b/frontend/src/pages/Settings/DepartmentTagSettings.jsx deleted file mode 100644 index 505525a..0000000 --- a/frontend/src/pages/Settings/DepartmentTagSettings.jsx +++ /dev/null @@ -1,340 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Alert, Button, Card, Input, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message, -} from 'antd'; -import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, TagsOutlined, TeamOutlined } from '@ant-design/icons'; -import { - getDepartmentTagConfig, - saveDepartmentTagConfig, -} from '../../api/productMonitoring'; - -const { Text } = Typography; - -const METRIC_OPTIONS = [ - { value: 'sales_qty_7d', label: '近7天销售件数' }, - { value: 'sales_amount_7d', label: '近7天销售额' }, -]; -const OWNER_TYPE_OPTIONS = [ - { value: 'sku_owner', label: 'SKU负责人' }, - { value: 'purchaser', label: '采购负责人' }, -]; -const LEVEL_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' }; - -const readError = (error) => error?.response?.data?.error || error?.message || '操作失败'; - -const useDepartmentConfig = () => { - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - const load = useCallback(async () => { - setLoading(true); - setError(''); - try { - const result = await getDepartmentTagConfig(); - setConfig(result?.data || null); - } catch (requestError) { - setError(readError(requestError)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { load(); }, [load]); - - return { config, setConfig, loading, setLoading, error, setError, load }; -}; - -// ==================== 部门商品标签配置(G-S / G-A / G-B / G-C) ==================== - -export const DepartmentTagSettings = () => { - const { config, setConfig, loading, error, load } = useDepartmentConfig(); - const [rules, setRules] = useState([]); - const [saving, setSaving] = useState(false); - - useEffect(() => { - if (config?.rules) setRules(config.rules.map((item) => ({ ...item }))); - }, [config]); - - const patch = (tagLevel, key, value) => { - setRules((old) => old.map((item) => (item.tagLevel === tagLevel ? { ...item, [key]: value } : item))); - }; - - const handleSave = async () => { - setSaving(true); - try { - const result = await saveDepartmentTagConfig({ rules }); - setConfig(result?.data || null); - message.success('部门商品标签已保存,商品监控列表下次加载即生效'); - } catch (requestError) { - message.error(readError(requestError)); - } finally { - setSaving(false); - } - }; - - const columns = [ - { - title: '部门标签', - dataIndex: 'tagLevel', - width: 120, - render: (value) => {value}, - }, - { - title: '判定指标', - dataIndex: 'metricKey', - width: 180, - render: (value, row) => ( - patch(row.tagLevel, 'thresholdMin', e.target.value === '' ? null : Number(e.target.value))} - /> - ), - }, - { - title: '区间终点(不含)', - dataIndex: 'thresholdMax', - width: 160, - render: (value, row) => ( - patch(row.tagLevel, 'thresholdMax', e.target.value === '' ? null : Number(e.target.value))} - /> - ), - }, - { - title: '优先级', - dataIndex: 'priority', - width: 110, - render: (value, row) => ( - patch(row.tagLevel, 'priority', Number(e.target.value) || 0)} - /> - ), - }, - { - title: '启用', - dataIndex: 'enabled', - width: 90, - render: (value, row) => ( - patch(row.tagLevel, 'enabled', next)} /> - ), - }, - ]; - - return ( -
- - 部门商品标签配置} - extra={( - - - - - )} - > - {error ? : null} - - 商品监控页「部门的标签」按此规则判定:同一 SKU 的近 7 天指标落在 [起点, 终点) 区间即命中, - 按优先级升序命中即停,未命中留空。留空表示不限(如 G-S 只填起点 500)。 - -
- - - - ); -}; - -// ==================== 部门 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) => ( - patch(row.key, 'spu', e.target.value)} - /> - ), - }, - { - title: '负责人类型', - dataIndex: 'ownerType', - width: 150, - render: (value, row) => ( - patch(row.key, 'userId', next)} - style={{ width: '100%' }} - /> - ), - }, - { - title: '操作', - width: 90, - render: (_, row) => ( - removeRow(row.key)}> - - - ), - }, - ]; - - return ( -
- - 部门 SKU 负责人配置} - extra={( - - - - - - )} - > - {error ? : null} - - 商品监控页「部门SKU负责人」按此表匹配:先找部门 + SPU 精确配置,找不到再用部门 + SPU 留空的默认负责人。 - 保存会整体覆盖当前配置。 - -
- - - - ); -}; - -export default DepartmentTagSettings; diff --git a/frontend/src/pages/Settings/ProductTagSettings.jsx b/frontend/src/pages/Settings/ProductTagSettings.jsx deleted file mode 100644 index cdb3b26..0000000 --- a/frontend/src/pages/Settings/ProductTagSettings.jsx +++ /dev/null @@ -1,372 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Input, InputNumber, Select, Space, Spin, Table, Tag, Typography, message } from 'antd'; -import { ReloadOutlined, SaveOutlined, SearchOutlined } from '@ant-design/icons'; -import { getProductTagConfig, updateProductTagConfig } from '../../api/admin'; -import { SycmCard } from '../../components/Common'; -import './productTagSettings.css'; - -const SCALE_TAGS = ['爆款', '热卖款', '动销款', '低动销款']; -const SCALE_TAG_ORDER = [...SCALE_TAGS, '滞销款']; -const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级']; -const SABC_TAGS = ['S', 'A', 'B', 'C', 'D']; - -// 规模标签配色:爆款最优 → 滞销款最差 -const SCALE_TAG_COLOR = { - 爆款: 'red', - 热卖款: 'orange', - 动销款: 'blue', - 低动销款: 'default', - 滞销款: 'default', -}; - -const SABC_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' }; - -const numText = (value) => ( - value === null || value === undefined || value === '' - ? '--' - : Number(value).toLocaleString('zh-CN') -); - -const scaleRowKey = (row) => `${row.category}||${row.scaleTag}`; - -const ProductTagSettings = () => { - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [scaleThresholds, setScaleThresholds] = useState([]); - const [profitLevels, setProfitLevels] = useState([]); - const [sabcMappings, setSabcMappings] = useState([]); - const [excludedCategories, setExcludedCategories] = useState([]); - const [categoryFilter, setCategoryFilter] = useState(''); - const [scaleTagFilter, setScaleTagFilter] = useState(); - - const applyConfig = useCallback((config = {}) => { - setScaleThresholds(Array.isArray(config.scaleThresholds) ? config.scaleThresholds : []); - setProfitLevels(Array.isArray(config.profitLevels) ? config.profitLevels : []); - setSabcMappings(Array.isArray(config.sabcMappings) ? config.sabcMappings : []); - setExcludedCategories(Array.isArray(config.excludedCategories) ? config.excludedCategories : []); - }, []); - - const loadConfig = useCallback(async () => { - setLoading(true); - try { - const response = await getProductTagConfig(); - applyConfig(response?.data?.data || response?.data || {}); - } catch (error) { - message.error(error?.response?.data?.error || error?.message || '加载商品标签配置失败'); - } finally { - setLoading(false); - } - }, [applyConfig]); - - useEffect(() => { - loadConfig(); - }, [loadConfig]); - - const handleSave = async () => { - setSaving(true); - try { - const response = await updateProductTagConfig({ - scaleThresholds, - profitLevels, - sabcMappings, - excludedCategories, - }); - applyConfig(response?.data?.data || response?.data || {}); - message.success( - `已保存:规模阈值 ${scaleThresholds.length} 行 / 利润标签 ${profitLevels.length} 行 / ` - + `SABC 映射 ${sabcMappings.length} 条 / 排除品类 ${excludedCategories.length} 个` - ); - } catch (error) { - message.error(error?.response?.data?.error || error?.message || '保存商品标签配置失败'); - } finally { - setSaving(false); - } - }; - - // 规模阈值:原地更新某一行 - const patchScaleRow = useCallback((rowKey, field, value) => { - setScaleThresholds((rows) => rows.map((row) => ( - scaleRowKey(row) === rowKey ? { ...row, [field]: value } : row - ))); - }, []); - - // SABC 映射:矩阵单元格更新(不存在则新增) - const patchSabcCell = useCallback((scaleTag, profitLevel, sabcTag) => { - setSabcMappings((rows) => { - const index = rows.findIndex((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel); - if (index >= 0) { - const next = [...rows]; - next[index] = { ...next[index], sabcTag }; - return next; - } - return [...rows, { scaleTag, profitLevel, sabcTag, enabled: true }]; - }); - }, []); - - const sabcValueOf = useCallback((scaleTag, profitLevel) => { - const hit = sabcMappings.find((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel); - return hit?.sabcTag; - }, [sabcMappings]); - - // 候选品类:已配置的品类(含 jst_sku.category 全名)作为联想项, - // 未配置的(如「其他 - 配件」)直接手动输入完整值。 - const dbCategoryOptions = useMemo(() => { - const set = new Set(); - scaleThresholds.forEach((row) => { - if (row.category) set.add(row.category); - }); - excludedCategories.forEach((item) => set.add(item)); - return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN')); - }, [scaleThresholds, excludedCategories]); - - const filteredScaleRows = useMemo(() => { - const keyword = categoryFilter.trim(); - return scaleThresholds.filter((row) => ( - (!keyword || String(row.displayName || '').includes(keyword) || String(row.category || '').includes(keyword)) - && (!scaleTagFilter || row.scaleTag === scaleTagFilter) - )); - }, [scaleThresholds, categoryFilter, scaleTagFilter]); - - const scaleColumns = [ - { - title: '品类', - dataIndex: 'displayName', - width: 200, - fixed: 'left', - filters: SCALE_TAGS.map((tag) => ({ text: tag, value: tag })), - onFilter: (value, record) => record.scaleTag === value, - render: (value, record) => ( -
-
{value || record.category}
- {/* 展示 jst_sku.category 的完整值,便于核对品类名映射 */} - {record.category && record.category !== value ? ( -
{record.category}
- ) : null} -
- ), - }, - { - title: '规模标签', - dataIndex: 'scaleTag', - width: 110, - render: (value) => {value}, - }, - { - title: '业绩起点', - dataIndex: 'performanceMin', - width: 130, - align: 'right', - render: (value, record) => ( - patchScaleRow(scaleRowKey(record), 'performanceMin', next)} - /> - ), - }, - { - title: '日销起点', - dataIndex: 'salesMin', - width: 120, - align: 'right', - // 日销存在 0.5 这类合法小数(低动销款),不能用 precision=0 强制取整 - render: (value, record) => ( - 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) => ( - setProfitLevels((rows) => rows.map((row) => ( - row.levelCode === record.levelCode ? { ...row, profitRateMax: next } : row - )))} - /> - ), - }, - { - title: '说明(展示用,不参与计算)', - dataIndex: 'description', - render: (value, record) => ( - 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) => {value}, - }, - ...PROFIT_LEVELS.map((level) => ({ - title: level, - dataIndex: level, - width: 110, - align: 'center', - render: (_, record) => ( - } - placeholder="搜索分类" - value={categoryFilter} - onChange={(event) => setCategoryFilter(event.target.value)} - style={{ width: 220 }} - /> -
`共 ${total} 行` }} - scroll={{ x: 760 }} - /> - - - - -
- - - 规模标签 × 利润标签 → 公司商品标签} - > -
- - 取值说明: - {SABC_TAGS.map((tag) => ( - {tag} - ))} - (S 最优,D 最差;D 档为本次评审确认保留) - - - - {`已排除 ${excludedCategories.length} 个`}} - > - -